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
13 changes: 13 additions & 0 deletions apps/desktop/src/main/rendererCsp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,19 @@ describe("buildRendererCspPolicy", () => {
expect(policy).toContain("frame-src 'self' file: app: http://localhost:* http://127.0.0.1:* about:");
});

it("allows only the welcome video YouTube embed hosts for external frames", () => {
const policy = buildRendererCspPolicy(false);
const frameSrc = policy.split("; ").find((directive) => directive.startsWith("frame-src "));
const frameTokens = frameSrc?.split(/\s+/).slice(1) ?? [];
const externalFrameTokens = frameTokens.filter((token) => token.startsWith("https://"));

expect(externalFrameTokens).toEqual([
"https://www.youtube-nocookie.com",
"https://www.youtube.com",
]);
expect(frameTokens).not.toContain("https:");
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});

it("does not allow arbitrary public Google Cloud Storage image beacons", () => {
const policy = buildRendererCspPolicy(false);

Expand Down
3 changes: 2 additions & 1 deletion apps/desktop/src/main/rendererCsp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,14 @@ export function buildRendererCspPolicy(isDevMode: boolean): string {
// the allowlist host-scoped (no blanket `https:`) to preserve the existing
// posture of not allowing arbitrary public image beacons.
const cspImageSources = `${cspSources}${cspLocalSources} https://avatars.githubusercontent.com https://*.githubusercontent.com https://user-images.githubusercontent.com https://private-user-images.githubusercontent.com https://media.githubusercontent.com https://camo.githubusercontent.com https://objects.githubusercontent.com https://github.githubassets.com https://opengraph.githubassets.com https://github.com https://vercel.com https://*.vercel.com https://img.shields.io https://*.s3.amazonaws.com https://www.gravatar.com https://secure.gravatar.com https://ade-app.dev`;
const cspFrameSources = `${cspSources}${cspLocalSources} about: https://www.youtube-nocookie.com https://www.youtube.com`;
const cspScriptSources = isDevMode ? `${cspSources} 'unsafe-inline'` : cspSources;
return [
`default-src ${cspSources}`,
`base-uri 'self'`,
`form-action 'self'`,
`object-src 'none'`,
`frame-src ${cspSources}${cspLocalSources} about:`,
`frame-src ${cspFrameSources}`,
`script-src ${cspScriptSources}`,
`style-src ${cspSources} 'unsafe-inline'`,
`img-src ${cspImageSources} ade-artifact: data: blob:`,
Expand Down
13 changes: 0 additions & 13 deletions apps/desktop/src/main/services/adeActions/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -502,21 +502,8 @@ export const ADE_ACTION_ALLOWLIST: Partial<Record<AdeActionDomain, readonly stri
"detectDefaults",
"detectExistingLanes",
"getStatus",
"getTourProgress",
"markGlossaryTermSeen",
"markTourCompleted",
"markTourDismissed",
"markTutorialCompleted",
"markTutorialDismissed",
"markTutorialStarted",
"markWizardCompleted",
"markWizardDismissed",
"resetTourProgress",
"setDismissed",
"setTutorialSilenced",
"shouldPromptTutorial",
"updateTourStep",
"updateTutorialAct",
],
automation_planner: ["parseNaturalLanguage", "saveDraft", "simulate", "validateDraft"],
cto_state: [
Expand Down
249 changes: 59 additions & 190 deletions apps/desktop/src/main/services/ipc/registerIpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import type {
AdoptAttachedLaneArgs,
UnregisteredLaneCandidate,
AppInfo,
AppWelcomeVideoState,
AppResourceUsageSnapshot,
PtyProcessResourceUsageSnapshot,
LatestReleaseInfo,
Expand Down Expand Up @@ -309,9 +310,8 @@ import type {
ImportBranchLaneArgs,
OnboardingDetectionResult,
OnboardingExistingLaneCandidate,
OnboardingHelpState,
OnboardingStatus,
OnboardingTourProgress,
OnboardingTourVariant,
LaneLinearIssue,
LaneListSnapshot,
LaneSummary,
Expand Down Expand Up @@ -571,6 +571,7 @@ import type { createAutomationIngressService } from "../automations/automationIn
import type { createGithubPollingService } from "../automations/githubPollingService";
import { ADE_ACTION_ALLOWLIST, getAdeActionDomainServices, listAllowedAdeActionNames } from "../adeActions/registry";
import type { AdeRuntime } from "../../../../../ade-cli/src/bootstrap";
import { ADE_WELCOME_VIDEO_ID, ADE_WELCOME_VIDEO_VERSION } from "../../../shared/welcomeVideo";

import type { createOrchestrationService } from "../orchestration/orchestrationService";
import { createOrchestrationDomainService } from "../orchestration/orchestrationDomain";
Expand Down Expand Up @@ -1642,6 +1643,35 @@ export function registerIpc({
return service;
};

const normalizeWelcomeVideoTimestamp = (value: unknown): string | null => {
if (typeof value !== "string") return null;
const trimmed = value.trim();
if (!trimmed || Number.isNaN(Date.parse(trimmed))) return null;
return trimmed;
};

const normalizeWelcomeVideoState = (
value: AppWelcomeVideoState | null | undefined,
): AppWelcomeVideoState => {
if (
value?.videoId === ADE_WELCOME_VIDEO_ID &&
value.version === ADE_WELCOME_VIDEO_VERSION
) {
return {
videoId: ADE_WELCOME_VIDEO_ID,
version: ADE_WELCOME_VIDEO_VERSION,
completedAt: normalizeWelcomeVideoTimestamp(value.completedAt),
dismissedAt: normalizeWelcomeVideoTimestamp(value.dismissedAt),
};
}
return {
videoId: ADE_WELCOME_VIDEO_ID,
version: ADE_WELCOME_VIDEO_VERSION,
completedAt: null,
dismissedAt: null,
};
};

const getLocalRuntimeRootForEvent = (event: { sender: Electron.WebContents }): string | null => {
if (!getWindowSession) return null;
const windowId = BrowserWindow.fromWebContents(event.sender)?.id ?? null;
Expand Down Expand Up @@ -2998,6 +3028,30 @@ export function registerIpc({
};
});

ipcMain.handle(IPC.appGetWelcomeVideoState, async (): Promise<AppWelcomeVideoState> => {
const state = readGlobalState(globalStatePath);
return normalizeWelcomeVideoState(state.welcomeVideo);
});

ipcMain.handle(
IPC.appMarkWelcomeVideoSeen,
async (_event, arg: { reason?: "completed" | "dismissed" } = {}): Promise<AppWelcomeVideoState> => {
const state = readGlobalState(globalStatePath);
const current = normalizeWelcomeVideoState(state.welcomeVideo);
const timestamp = new Date().toISOString();
const next: AppWelcomeVideoState = {
...current,
completedAt: arg.reason === "completed" ? timestamp : current.completedAt,
dismissedAt: arg.reason === "completed" ? current.dismissedAt : timestamp,
};
writeGlobalState(globalStatePath, {
...state,
welcomeVideo: next,
});
return next;
},
);

ipcMain.handle(IPC.appSetWindowProjectTabs, async (event, arg: { rootPaths?: string[] } = {}) => {
const windowId = BrowserWindow.fromWebContents(event.sender)?.id ?? null;
const rootPaths = Array.isArray(arg?.rootPaths)
Expand Down Expand Up @@ -4489,202 +4543,17 @@ export function registerIpc({
return ctx.onboardingService.complete();
});

const emptyTourProgress = (): OnboardingTourProgress => ({
wizardCompletedAt: null,
wizardDismissedAt: null,
tours: {},
tourVariants: {},
tutorial: {
completedAt: null,
dismissedAt: null,
silenced: false,
inProgress: false,
lastActIndex: 0,
ctxSnapshot: {},
},
glossaryTermsSeen: [],
});

const coerceVariant = (raw: unknown): OnboardingTourVariant =>
raw === "highlights" ? "highlights" : "full";

ipcMain.handle(IPC.onboardingGetTourProgress, async (): Promise<OnboardingTourProgress> => {
const ctx = getCtx();
if (!ctx.onboardingService) return emptyTourProgress();
return ctx.onboardingService.getTourProgress();
});

ipcMain.handle(IPC.onboardingMarkWizardCompleted, async (): Promise<OnboardingTourProgress> => {
const ctx = getCtx();
if (!ctx.onboardingService) return emptyTourProgress();
return ctx.onboardingService.markWizardCompleted();
});

ipcMain.handle(IPC.onboardingMarkWizardDismissed, async (): Promise<OnboardingTourProgress> => {
const ctx = getCtx();
if (!ctx.onboardingService) return emptyTourProgress();
return ctx.onboardingService.markWizardDismissed();
});

ipcMain.handle(
IPC.onboardingMarkTourCompleted,
async (_event, arg: { tourId: string }): Promise<OnboardingTourProgress> => {
const ctx = getCtx();
if (!ctx.onboardingService) return emptyTourProgress();
return ctx.onboardingService.markTourCompleted(arg?.tourId ?? "");
},
);

ipcMain.handle(
IPC.onboardingMarkTourDismissed,
async (_event, arg: { tourId: string }): Promise<OnboardingTourProgress> => {
const ctx = getCtx();
if (!ctx.onboardingService) return emptyTourProgress();
return ctx.onboardingService.markTourDismissed(arg?.tourId ?? "");
},
);

ipcMain.handle(
IPC.onboardingUpdateTourStep,
async (_event, arg: { tourId: string; index: number }): Promise<OnboardingTourProgress> => {
const ctx = getCtx();
if (!ctx.onboardingService) return emptyTourProgress();
const index = typeof arg?.index === "number" ? arg.index : 0;
return ctx.onboardingService.updateTourStep(arg?.tourId ?? "", index);
},
);
const emptyHelpState = (): OnboardingHelpState => ({ glossaryTermsSeen: [] });

ipcMain.handle(
IPC.onboardingMarkGlossaryTermSeen,
async (_event, arg: { termId: string }): Promise<OnboardingTourProgress> => {
async (_event, arg: { termId: string }): Promise<OnboardingHelpState> => {
const ctx = getCtx();
if (!ctx.onboardingService) return emptyTourProgress();
if (!ctx.onboardingService) return emptyHelpState();
return ctx.onboardingService.markGlossaryTermSeen(arg?.termId ?? "");
},
);

ipcMain.handle(
IPC.onboardingResetTourProgress,
async (_event, arg?: { tourId?: string }): Promise<OnboardingTourProgress> => {
const ctx = getCtx();
if (!ctx.onboardingService) return emptyTourProgress();
return ctx.onboardingService.resetTourProgress(arg?.tourId);
},
);

// Variant-aware tour progress (Round 2) ---------------------------------

ipcMain.handle(
IPC.onboardingMarkTourCompletedVariant,
async (
_event,
arg: { tourId: string; variant: OnboardingTourVariant },
): Promise<OnboardingTourProgress> => {
const ctx = getCtx();
if (!ctx.onboardingService) return emptyTourProgress();
return ctx.onboardingService.markTourCompleted(
arg?.tourId ?? "",
coerceVariant(arg?.variant),
);
},
);

ipcMain.handle(
IPC.onboardingMarkTourDismissedVariant,
async (
_event,
arg: { tourId: string; variant: OnboardingTourVariant },
): Promise<OnboardingTourProgress> => {
const ctx = getCtx();
if (!ctx.onboardingService) return emptyTourProgress();
return ctx.onboardingService.markTourDismissed(
arg?.tourId ?? "",
coerceVariant(arg?.variant),
);
},
);

ipcMain.handle(
IPC.onboardingUpdateTourStepVariant,
async (
_event,
arg: { tourId: string; variant: OnboardingTourVariant; index: number },
): Promise<OnboardingTourProgress> => {
const ctx = getCtx();
if (!ctx.onboardingService) return emptyTourProgress();
const index = typeof arg?.index === "number" ? arg.index : 0;
return ctx.onboardingService.updateTourStep(
arg?.tourId ?? "",
coerceVariant(arg?.variant),
index,
);
},
);

// Tutorial (Round 2) ----------------------------------------------------

ipcMain.handle(IPC.onboardingTutorialStart, async (): Promise<OnboardingTourProgress> => {
const ctx = getCtx();
if (!ctx.onboardingService) return emptyTourProgress();
return ctx.onboardingService.markTutorialStarted();
});

ipcMain.handle(
IPC.onboardingTutorialDismiss,
async (_event, arg?: { permanent?: boolean }): Promise<OnboardingTourProgress> => {
const ctx = getCtx();
if (!ctx.onboardingService) return emptyTourProgress();
return ctx.onboardingService.markTutorialDismissed(Boolean(arg?.permanent));
},
);

ipcMain.handle(IPC.onboardingTutorialComplete, async (): Promise<OnboardingTourProgress> => {
const ctx = getCtx();
if (!ctx.onboardingService) return emptyTourProgress();
return ctx.onboardingService.markTutorialCompleted();
});

ipcMain.handle(
IPC.onboardingTutorialUpdateAct,
async (
_event,
arg: { actIndex: number; ctxSnapshot?: Record<string, unknown> },
): Promise<OnboardingTourProgress> => {
const ctx = getCtx();
if (!ctx.onboardingService) return emptyTourProgress();
const actIndex = typeof arg?.actIndex === "number" ? arg.actIndex : 0;
const snapshot =
arg?.ctxSnapshot && typeof arg.ctxSnapshot === "object" && !Array.isArray(arg.ctxSnapshot)
? arg.ctxSnapshot
: undefined;
return ctx.onboardingService.updateTutorialAct(actIndex, snapshot);
},
);

ipcMain.handle(
IPC.onboardingTutorialSetSilenced,
async (_event, arg: { silenced: boolean }): Promise<OnboardingTourProgress> => {
const ctx = getCtx();
if (!ctx.onboardingService) return emptyTourProgress();
return ctx.onboardingService.setTutorialSilenced(Boolean(arg?.silenced));
},
);

ipcMain.handle(
IPC.onboardingTutorialClearSessionDismissal,
async (): Promise<OnboardingTourProgress> => {
const ctx = getCtx();
if (!ctx.onboardingService) return emptyTourProgress();
return ctx.onboardingService.clearTutorialSessionDismissal();
},
);

ipcMain.handle(IPC.onboardingTutorialShouldPrompt, async (): Promise<boolean> => {
const ctx = getCtx();
if (!ctx.onboardingService) return false;
return ctx.onboardingService.shouldPromptTutorial();
});

const ensureAutomationContext = (): AppContextWith<"automationService"> => {
const ctx = getCtx();
requireAppContextServices(ctx, ["automationService"] as const);
Expand Down
Loading
Loading