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
2 changes: 1 addition & 1 deletion src/lib/api/infrastructure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ function enrich403Detail(rawDetail: string | undefined): string {
*
* @see https://github.com/getsentry/sentry/blob/934f1473f198a62f9268d7140b80cd9ca1e59bb9/src/sentry/api/authentication.py#L536-L539
*/
function enrich401Detail(rawDetail: string | undefined): string {
export function enrich401Detail(rawDetail: string | undefined): string {
const lines: string[] = [];
if (rawDetail) {
lines.push(rawDetail, "");
Expand Down
6 changes: 4 additions & 2 deletions src/lib/init/constants.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
export const MASTRA_API_URL =
process.env.MASTRA_API_URL ??
export const DEFAULT_MASTRA_API_URL =
"https://sentry-init-agent.getsentry.workers.dev";

export const MASTRA_API_URL =
process.env.MASTRA_API_URL ?? DEFAULT_MASTRA_API_URL;

export const WORKFLOW_ID = "sentry-wizard";

export const SENTRY_DOCS_URL = "https://docs.sentry.io/platforms/";
Expand Down
58 changes: 58 additions & 0 deletions src/lib/init/init-service-auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { enrich401Detail } from "../api/infrastructure.js";
import { ApiError, HostScopeError } from "../errors.js";
import { isSaaSTrustOrigin, normalizeOrigin } from "../sentry-urls.js";
import { getActiveTokenHost } from "../token-host.js";
import {
DEFAULT_MASTRA_API_URL,
MASTRA_API_URL,
WORKFLOW_ID,
} from "./constants.js";

const INIT_SERVICE_REJECTED_TOKEN_MESSAGE =
"Sentry Init setup service rejected your authentication token.";
const WORKFLOW_ENDPOINT = `/api/workflows/${WORKFLOW_ID}`;
export const WORKFLOW_CREATE_RUN_ENDPOINT = `${WORKFLOW_ENDPOINT}/create-run`;
export const WORKFLOW_RESUME_ASYNC_ENDPOINT = `${WORKFLOW_ENDPOINT}/resume-async`;
export const WORKFLOW_START_ASYNC_ENDPOINT = `${WORKFLOW_ENDPOINT}/start-async`;
const MASTRA_HTTP_401_RE = /HTTP error!\s*status:\s*401\b/i;

function classifyInitServiceAuthFailure(
err: unknown,
endpoint: string
): ApiError | null {
if (!(err instanceof Error && MASTRA_HTTP_401_RE.test(err.message))) {
return null;
}

return new ApiError(
INIT_SERVICE_REJECTED_TOKEN_MESSAGE,
401,
enrich401Detail("Unauthorized: invalid token"),
endpoint
);
}

export async function withInitServiceAuthClassification<T>(
operation: () => Promise<T>,
endpoint: string
): Promise<T> {
try {
return await operation();
} catch (err) {
throw classifyInitServiceAuthFailure(err, endpoint) ?? err;
}
}

export function assertHostedInitServiceAcceptsTokenHost(): void {
const tokenHost = getActiveTokenHost();
const usesHostedInitService =
normalizeOrigin(MASTRA_API_URL) === normalizeOrigin(DEFAULT_MASTRA_API_URL);

if (tokenHost && usesHostedInitService && !isSaaSTrustOrigin(tokenHost)) {
throw new HostScopeError(
"Hosted Sentry Init setup service",
"https://sentry.io",
tokenHost
);
}
}
80 changes: 58 additions & 22 deletions src/lib/init/wizard-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import { formatBanner } from "../banner.js";
import { CLI_VERSION } from "../constants.js";
import { customFetch } from "../custom-ca.js";
import { detectAgent } from "../detect-agent.js";
import { EXIT, WizardError } from "../errors.js";
import { ApiError, EXIT, WizardError } from "../errors.js";
import {
renderInlineMarkdown,
stripColorTags,
Expand All @@ -48,6 +48,13 @@ import {
} from "./constants.js";
import { formatError, formatResult } from "./formatters.js";
import { checkGitStatus } from "./git.js";
import {
assertHostedInitServiceAcceptsTokenHost,
WORKFLOW_CREATE_RUN_ENDPOINT,
WORKFLOW_RESUME_ASYNC_ENDPOINT,
WORKFLOW_START_ASYNC_ENDPOINT,
withInitServiceAuthClassification,
} from "./init-service-auth.js";
import { handleInteractive } from "./interactive.js";
import { resolveInitContext } from "./preflight.js";
import { checkReadiness } from "./readiness.js";
Expand All @@ -73,6 +80,8 @@ import {

type SpinState = { running: boolean };

Comment thread
betegon marked this conversation as resolved.
const INIT_SERVICE_AUTH_FAILED_LABEL = "Authentication failed";

const APPLY_CODEMODS_STEP = "apply-codemods";

type CompactPhaseHistoryEntry = {
Expand Down Expand Up @@ -654,7 +663,10 @@ async function resumeWithRecovery(
} = args;
try {
const raw = await withTimeout(
run.resumeAsync({ step: stepId, resumeData, tracingOptions }),
withInitServiceAuthClassification(
() => run.resumeAsync({ step: stepId, resumeData, tracingOptions }),
WORKFLOW_RESUME_ASYNC_ENDPOINT
),
API_TIMEOUT_MS,
"Workflow resume"
);
Expand Down Expand Up @@ -777,6 +789,7 @@ export async function runWizard(initialOptions: WizardOptions): Promise<void> {
},
};

assertHostedInitServiceAcceptsTokenHost();
const token = context.authToken;

// AbortController bound to the MastraClient lifecycle. Aborting on
Expand Down Expand Up @@ -833,7 +846,10 @@ export async function runWizard(initialOptions: WizardOptions): Promise<void> {
const fileCache = await preReadCommonFiles(directory, dirListing);
ui.setIntroMode?.(false);
spin.message("Connecting to wizard...");
run = await workflow.createRun();
run = await withInitServiceAuthClassification(
() => workflow.createRun(),
WORKFLOW_CREATE_RUN_ENDPOINT
);
// Large shared context (dirListing, fileCache, existingSentry)
// travels via Mastra's workflow `initialState` instead of `inputData`.
// Keeping it on state means the server stores it exactly once per run
Expand All @@ -843,26 +859,36 @@ export async function runWizard(initialOptions: WizardOptions): Promise<void> {
// error. See getsentry/cli-init-api#98.
result = assertWorkflowResult(
await withTimeout(
run.startAsync({
inputData: {
directory,
yes,
dryRun,
features,
},
initialState: {
dirListing,
fileCache,
existingSentry: existingSentry?.data,
knownPlatform: context.existingProject?.platform,
},
tracingOptions,
}),
withInitServiceAuthClassification(
() =>
run.startAsync({
inputData: {
directory,
yes,
dryRun,
features,
},
initialState: {
dirListing,
fileCache,
existingSentry: existingSentry?.data,
knownPlatform: context.existingProject?.platform,
},
tracingOptions,
}),
WORKFLOW_START_ASYNC_ENDPOINT
),
API_TIMEOUT_MS,
"Workflow start"
)
);
} catch (err) {
if (err instanceof ApiError && err.status === 401) {
spin.stop(INIT_SERVICE_AUTH_FAILED_LABEL, 1);
spinState.running = false;
showFailedFeedback(ui, INIT_SERVICE_AUTH_FAILED_LABEL);
throw err;
}
Comment thread
betegon marked this conversation as resolved.
spin.stop("Connection failed", 1);
spinState.running = false;
ui.log.error(errorMessage(err));
Expand Down Expand Up @@ -942,13 +968,18 @@ export async function runWizard(initialOptions: WizardOptions): Promise<void> {
});
}
} catch (err) {
const isAuthFailure = err instanceof ApiError && err.status === 401;
// A running spinner owns a live interval, so stop it before any early
// return or rethrow to avoid leaving the event loop artificially busy.
if (spinState.running) {
const [label, code] =
err instanceof WizardCancelledError
? (["Cancelled", 0] as const)
: (["Error", 1] as const);
let label = "Error";
let code: 0 | 1 = 1;
if (err instanceof WizardCancelledError) {
label = "Cancelled";
code = 0;
} else if (isAuthFailure) {
label = INIT_SERVICE_AUTH_FAILED_LABEL;
}
spin.stop(label, code);
spinState.running = false;
}
Expand All @@ -965,6 +996,11 @@ export async function runWizard(initialOptions: WizardOptions): Promise<void> {
if (activeStepId) {
ui.setStep?.(activeStepId, "failed");
}
if (isAuthFailure) {
showFailedFeedback(ui, INIT_SERVICE_AUTH_FAILED_LABEL);
setTag("wizard.outcome", "errored");
throw err;
}
if (err instanceof WizardError) {
showFailedFeedback(ui);
setTag("wizard.outcome", "errored");
Expand Down
Loading
Loading