Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions apps/cli/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,9 @@ const stepMainOptions: MainOptions = {
syncStepLoginProfileEndpoint(getStepAuthPath());
return {
removed: report.removedNative || report.removedLegacy,
// The terminal belongs to the TUI here, so the host reports this
// instead of writing to stderr underneath it.
accountSessionRemoved: report.removedLoginState,
remainingSource: report.remainingSource ? "the environment (STEP_API_KEY)" : null,
};
},
Expand Down Expand Up @@ -401,6 +404,11 @@ try {
} else {
process.stdout.write(`No stored Step credential at ${report.nativePath}\n`);
}
if (!report.removedLoginState) {
process.stderr.write(
"Note: the account session could not be removed and is still on disk. Run `step logout` again.\n",
);
}
if (report.remainingSource) {
process.stderr.write("Note: STEP_API_KEY is still set, so requests will keep working.\n");
} else {
Expand Down Expand Up @@ -603,6 +611,18 @@ try {
? "credential is invalid or expired"
: "could not check credential validity (network error)";
process.stdout.write(`Validity: ${validity}.\n`);
// The account session is reported next to the key, never instead
// of it: a user who only ever had a key still has a working key.
const accountSession = status.session;
if (accountSession && accountSession.state !== "missing") {
const detail =
accountSession.state === "expired"
? "expired; account features will ask you to sign in again"
: accountSession.state === "expiring"
? "expiring; it refreshes on next use"
: "active";
process.stdout.write(`Account session: ${detail}.\n`);
}
if (status.validity === "invalid") process.exitCode = 1;
}
}
Expand Down
3 changes: 3 additions & 0 deletions apps/cli/src/ui/interactive-mode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5737,6 +5737,9 @@ export class InteractiveMode {
const report = await this.options.stepLogout?.();
if (!report) return;
this.showStatus(report.removed ? "Signed out of Step." : "No stored Step credential.");
if (report.accountSessionRemoved === false) {
this.showWarning("The account session could not be removed and is still on disk. Run /logout again.");
}
if (report.remainingSource) {
this.showWarning(`An API key is still active from ${report.remainingSource}.`);
}
Expand Down
7 changes: 6 additions & 1 deletion packages/coding-agent/src/core/provider-composer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -265,7 +265,12 @@ function adaptOAuth(config: ExtensionOAuthConfig): OAuthAuth {
onSelect: (prompt) => callbacks.prompt({ type: "select", ...prompt }),
signal: callbacks.signal,
});
return { ...credential, type: "oauth" };
// A login flow may ride an account session along on the credential so
// the product layer can store it separately. It must not reach
// `auth.json`: credentials are persisted verbatim, and a copy there
// would never be refreshed.
const { login: _accountSession, ...credentialFields } = credential;
return { ...credentialFields, type: "oauth" };
},
refresh: async (credential, signal) => ({ ...(await config.refreshToken(credential, signal)), type: "oauth" }),
toAuth: async (credential) => ({ apiKey: config.getApiKey(credential) }),
Expand Down
11 changes: 11 additions & 0 deletions packages/coding-agent/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -611,6 +611,17 @@ export {
type StepLoginOutcome,
syncStepLoginProfileEndpoint,
} from "./step/login-flow.ts";
export {
buildStepLoginState,
clearStepLoginState,
getStepLoginStatePath,
getStepLoginToken,
readStepLoginState,
type StepLoginState,
type StepLoginTokenOptions,
type StepLoginTokenOutcome,
saveStepLoginState,
} from "./step/login-state.ts";
export {
getStepLoginStatus,
type StepCredentialValidity,
Expand Down
7 changes: 6 additions & 1 deletion packages/coding-agent/src/modes/interactive-contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,12 @@ export interface InteractiveModeOptions {
/** Shared Step onboarding flow used by the Step-only login surfaces. */
stepLogin?: (host: StepLoginHost) => Promise<StepLoginOutcome>;
/** Shared Step logout flow used by the Step-only TUI command. */
stepLogout?: () => Promise<{ removed: boolean; remainingSource?: string | null }>;
stepLogout?: () => Promise<{
removed: boolean;
remainingSource?: string | null;
/** False when an account session could not be removed and is still on disk. */
accountSessionRemoved?: boolean;
}>;
/**
* One-time offer to migrate another agent's MCP servers.
*
Expand Down
12 changes: 11 additions & 1 deletion packages/coding-agent/src/step/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import type { Credential } from "@step-harness/providers";
import { AuthStorage, readStoredCredential } from "../core/auth-storage.ts";
import { STEP_PROVIDER_ID, STEP_STATIC_REFRESH_TOKEN } from "../features/step-provider/index.ts";
import { LEGACY_RENAMED_CONFIG_DIR, resolveStepConfigRoot, resolveStepHomeDir } from "./environment.ts";
import { clearStepLoginState } from "./login-state.ts";

/** Current Pi-shaped credential path under the StepCode agent directory. */
export function getStepAuthPath(env: Record<string, string | undefined> = process.env): string {
Expand Down Expand Up @@ -125,6 +126,8 @@ export async function migrateLegacyStepCredential(
export interface StepLogoutResult {
removedNative: boolean;
removedLegacy: boolean;
/** True when no account session remains on disk. */
removedLoginState: boolean;
nativePath: string;
legacyPath: string;
remainingSource: "environment" | null;
Expand All @@ -136,6 +139,7 @@ export async function logoutStepCredentials(
env?: Record<string, string | undefined>;
} = {},
): Promise<StepLogoutResult> {
const env = options.env ?? process.env;
const nativePath = normalize(options.nativePath ?? getStepAuthPath());
const legacyPath = normalize(options.legacyPath ?? getLegacyStepAuthPath());
let removedNative = false;
Expand Down Expand Up @@ -172,10 +176,16 @@ export async function logoutStepCredentials(
}
}

const env = options.env ?? process.env;
// The account session is cleared last, and its failure never stops the API key
// from being removed. Whether anything is left on disk is reported, because a
// sign-out that claims success while a long-lived refresh token survives is
// worse than one that says otherwise.
const removedLoginState = await clearStepLoginState(env).catch(() => false);

return {
removedNative,
removedLegacy,
removedLoginState,
nativePath,
legacyPath,
remainingSource: env.STEP_API_KEY?.trim() ? "environment" : null,
Expand Down
91 changes: 85 additions & 6 deletions packages/coding-agent/src/step/login-flow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,15 @@ import { AuthStorage, readStoredCredential } from "../core/auth-storage.ts";
import {
defaultStepCliClientInfo,
loginStepOAuth,
parseStepCliLoginState,
STEP_PROVIDER_ID,
STEP_STATIC_REFRESH_TOKEN,
type StepCliLoginState,
} from "../features/step-provider/index.ts";
import { detectTerminalBackgroundFromEnv, initTheme, resolveThemeSetting, theme } from "../theme/theme.ts";
import { openBrowser } from "../utils/open-browser.ts";
import { resolveStepAgentDir } from "./environment.ts";
import { buildStepLoginState, clearStepLoginState, saveStepLoginState } from "./login-state.ts";
import {
INITIAL_STEP_LOGIN_STEP,
isStepLoginSettled,
Expand Down Expand Up @@ -58,8 +61,26 @@ export interface RunStepLoginOptions {
readonly requiredPlan?: "step_plan" | "step_plan_oversea";
}

/** Model discovery can return fallback models even when the account has no plan. */
export function getMissingStepPlanProfile(state: AccountUsageState): "step_plan" | "step_plan_oversea" | undefined {
/**
* Narrow the account session that rode along on the OAuth credentials.
*
* It arrives through an `unknown` bag, so it is validated here rather than
* trusted. This is the *parsed* shape; the wire shape is validated where the
* protocol defines it, in providers, so the two cannot drift apart.
*/
function readLoginState(value: unknown): StepCliLoginState | undefined {
return parseStepCliLoginState(value);
}

/** Both warnings matter; showing only one hides why account features are gone. */
function combineWarnings(...warnings: Array<string | undefined>): string | undefined {
const present = warnings.filter((warning): warning is string => Boolean(warning));
return present.length > 0 ? present.join("\n") : undefined;
}

/** Model discovery can return fallback models even when the account has no plan. */ export function getMissingStepPlanProfile(
state: AccountUsageState,
): "step_plan" | "step_plan_oversea" | undefined {
const { snapshot, account } = state;
if (state.status !== "ready" || !snapshot || !account.eligible) return undefined;
if (account.profile !== "step_plan" && account.profile !== "step_plan_oversea") return undefined;
Expand Down Expand Up @@ -229,6 +250,8 @@ export async function runStepLogin(options: RunStepLoginOptions = {}): Promise<S
: undefined;
let savedPath = options.requiredPlan ? authPath : undefined;
let warning: string | undefined;
/** Set when sign-in succeeded but the account session could not be stored. */
let loginWarning: string | undefined;
let settle: ((outcome: StepLoginOutcome) => void) | undefined;
const finished = new Promise<StepLoginOutcome>((resolve) => {
settle = resolve;
Expand Down Expand Up @@ -272,14 +295,23 @@ export async function runStepLogin(options: RunStepLoginOptions = {}): Promise<S
}
if (next.kind === "saving") {
const apiKey = event.type === "credential" ? event.apiKey.trim() : "";
await persist(next.choice, apiKey, event.type === "credential" ? event.uid : undefined);
await persist(
next.choice,
apiKey,
event.type === "credential" ? event.uid : undefined,
event.type === "credential" ? event.login : undefined,
event.type === "credential" ? event.loginError : undefined,
);
return;
}
if (next.kind === "checkingPlan") {
const account = await planUsage.query(true);
// Esc, cancellation, or a replacement login owns the view now.
if (step !== next) return;
warning = stepPlanCheckWarning(account);
// A failed account-session save is reported alongside the plan check,
// not replaced by it: only the browser path can carry a session, and
// that is exactly the path that runs this check.
warning = combineWarnings(stepPlanCheckWarning(account), loginWarning);
dispatch({ type: getMissingStepPlanProfile(account) ? "planRequired" : "saved" });
return;
}
Expand Down Expand Up @@ -329,14 +361,24 @@ export async function runStepLogin(options: RunStepLoginOptions = {}): Promise<S
type: "credential",
apiKey: credential.access,
uid: typeof credential.uid === "string" ? credential.uid : undefined,
// Absent on backends that predate the account session; the login
// then completes with the API key alone, as it always has.
login: readLoginState(credential.login),
loginError: typeof credential.loginError === "string" ? credential.loginError : undefined,
});
} catch (error) {
if (!controller.signal.aborted)
dispatch({ type: "fail", message: error instanceof Error ? error.message : String(error) });
}
}

async function persist(choice: StepLoginProfileId, apiKey: string, uid?: string): Promise<void> {
async function persist(
choice: StepLoginProfileId,
apiKey: string,
uid?: string,
login?: StepCliLoginState,
loginError?: string,
): Promise<void> {
const saving = step;
try {
const profile = profiles.find((candidate) => candidate.id === choice);
Expand All @@ -348,10 +390,47 @@ export async function runStepLogin(options: RunStepLoginOptions = {}): Promise<S
...(uid ? { uid } : {}),
obtainedAt: (options.now ?? (() => new Date()))().toISOString(),
});
// The account session is a separate file on purpose: it expires and
// refreshes on its own, and losing it must never cost the API key. Every
// failure here is a warning, never a failed sign-in — the key is already
// written by this point.
//
// Any previous session is dropped first. Signing in again — as another
// account, or with a profile that carries no session at all — must not
// leave the old account's session behind, or a host would read an
// identity that does not match the API key just written.
const rejected = loginError ? `The server rejected the account session (${loginError}).` : undefined;
const staleSession = "A previous account session may still be on disk and could be used by account features.";
let cleared = false;
try {
cleared = await clearStepLoginState(options.env ?? process.env);
const written = login
? await saveStepLoginState(buildStepLoginState(login, choice), options.env ?? process.env)
: true;
// A written session has already replaced whatever was there, so an
// unsuccessful clear is irrelevant in that case. Only a session that
// was neither written nor cleared leaves the old account's behind.
const replaced = cleared || (login !== undefined && written);
loginWarning = combineWarnings(
login && !written
? "Signed in, but the account session could not be written. Account features will ask you to sign in again."
: undefined,
replaced ? undefined : staleSession,
rejected,
);
} catch (error) {
loginWarning = combineWarnings(
`Signed in, but the account session could not be saved: ${
error instanceof Error ? error.message : String(error)
}.`,
cleared ? "Account features will ask you to sign in again." : staleSession,
rejected,
);
}
if (step !== saving) return;
savedProfile = profile;
savedPath = authPath;
warning = undefined;
warning = loginWarning;
syncStepLoginProfileEndpoint(authPath, options.env ?? process.env);
dispatch({ type: profile.credentialSource === "browser" ? "checkPlan" : "saved" });
} catch (error) {
Expand Down
Loading
Loading