Skip to content

Commit 04cc9f9

Browse files
committed
Add preview navigation approvals and model controls
- Gate desktop preview navigations through an approved-domain policy and surface blocked-navigation events to the renderer - Add PR template detection and related browser/source-control plumbing - Update text-generation model selection and prompt handling across server and web UI - Refresh tests for preview, provider, orchestration, and markdown/link behavior
1 parent a949a0b commit 04cc9f9

88 files changed

Lines changed: 4592 additions & 596 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/desktop/src/ipc/DesktopIpcHandlers.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ import {
6868
previewRevealElement,
6969
previewScroll,
7070
previewSetColorScheme,
71+
previewSetNavigationPolicy,
7172
previewSetViewport,
7273
previewSnapshot,
7374
previewStatus,
@@ -120,6 +121,7 @@ export const installDesktopIpcHandlers = Effect.gen(function* () {
120121
yield* ipc.handle(previewCancelPick);
121122
yield* ipc.handle(previewRevealElement);
122123
yield* ipc.handle(previewSetViewport);
124+
yield* ipc.handle(previewSetNavigationPolicy);
123125
yield* ipc.handle(previewClearBrowsingData);
124126
yield* ipc.handle(previewClearCache);
125127

apps/desktop/src/ipc/channels.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ export const PREVIEW_SET_ANNOTATION_MODE_CHANNEL = "desktop:preview-set-annotati
2323
export const PREVIEW_AWAIT_DRAWING_CHANNEL = "desktop:preview-await-drawing";
2424
export const PREVIEW_REVEAL_ELEMENT_CHANNEL = "desktop:preview-reveal-element";
2525
export const PREVIEW_SET_VIEWPORT_CHANNEL = "desktop:preview-set-viewport";
26+
export const PREVIEW_SET_NAVIGATION_POLICY_CHANNEL = "desktop:preview-set-navigation-policy";
27+
export const PREVIEW_NAVIGATION_BLOCKED_CHANNEL = "desktop:preview-navigation-blocked";
2628
export const PREVIEW_CLEAR_BROWSING_DATA_CHANNEL = "desktop:preview-clear-browsing-data";
2729
export const PREVIEW_CLEAR_CACHE_CHANNEL = "desktop:preview-clear-cache";
2830
export const SET_THEME_CHANNEL = "desktop:set-theme";

apps/desktop/src/ipc/methods/preview.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
DesktopPreviewViewportInputSchema,
88
DesktopPreviewEvaluateInputSchema,
99
DesktopPreviewMoveInputSchema,
10+
DesktopPreviewNavigationPolicyInputSchema,
1011
DesktopPreviewScreenshotSchema,
1112
DesktopPreviewSnapshotSchema,
1213
DesktopPreviewTypeInputSchema,
@@ -261,6 +262,16 @@ export const previewClearCache = makeIpcMethod({
261262
}),
262263
});
263264

265+
export const previewSetNavigationPolicy = makeIpcMethod({
266+
channel: IpcChannels.PREVIEW_SET_NAVIGATION_POLICY_CHANNEL,
267+
payload: DesktopPreviewNavigationPolicyInputSchema,
268+
result: Schema.Void,
269+
handler: Effect.fn("desktop.ipc.preview.setNavigationPolicy")(function* (input) {
270+
const automation = yield* PreviewAutomation.PreviewAutomation;
271+
yield* automation.setNavigationPolicy(input.webContentsId, input.approvedDomains);
272+
}),
273+
});
274+
264275
export const previewSetViewport = makeIpcMethod({
265276
channel: IpcChannels.PREVIEW_SET_VIEWPORT_CHANNEL,
266277
payload: DesktopPreviewViewportInputSchema,

apps/desktop/src/preload.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,19 @@ contextBridge.exposeInMainWorld("desktopBridge", {
129129
ipcRenderer.invoke(IpcChannels.PREVIEW_SET_COLOR_SCHEME_CHANNEL, input),
130130
previewSetViewport: (input) =>
131131
ipcRenderer.invoke(IpcChannels.PREVIEW_SET_VIEWPORT_CHANNEL, input),
132+
previewSetNavigationPolicy: (input) =>
133+
ipcRenderer.invoke(IpcChannels.PREVIEW_SET_NAVIGATION_POLICY_CHANNEL, input),
134+
onPreviewNavigationBlocked: (listener) => {
135+
const wrappedListener = (_event: Electron.IpcRendererEvent, blocked: unknown) => {
136+
if (typeof blocked !== "object" || blocked === null) return;
137+
listener(blocked as Parameters<typeof listener>[0]);
138+
};
139+
140+
ipcRenderer.on(IpcChannels.PREVIEW_NAVIGATION_BLOCKED_CHANNEL, wrappedListener);
141+
return () => {
142+
ipcRenderer.removeListener(IpcChannels.PREVIEW_NAVIGATION_BLOCKED_CHANNEL, wrappedListener);
143+
};
144+
},
132145
previewPickElement: (input) =>
133146
ipcRenderer.invoke(IpcChannels.PREVIEW_PICK_ELEMENT_CHANNEL, input),
134147
previewCancelPick: (input) => ipcRenderer.invoke(IpcChannels.PREVIEW_CANCEL_PICK_CHANNEL, input),

apps/desktop/src/preview/PreviewAutomation.ts

Lines changed: 125 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import type {
2525
DesktopPreviewEvaluateInput,
2626
DesktopPreviewNetworkFailure,
2727
DesktopPreviewMoveInput,
28+
DesktopPreviewNavigationBlocked,
2829
DesktopPreviewPressInput,
2930
DesktopPreviewScrollInput,
3031
DesktopPreviewSnapshot,
@@ -34,8 +35,10 @@ import type {
3435
DesktopPreviewWaitForInput,
3536
PreviewAutomationTarget,
3637
} from "@threadlines/contracts";
37-
import { webContents, type WebContents } from "electron";
38+
import { isBrowserHostApproved } from "@threadlines/shared/preview";
39+
import { BrowserWindow, webContents, type WebContents } from "electron";
3840

41+
import * as IpcChannels from "../ipc/channels.ts";
3942
import { toCdpKeyDefinition, toCdpModifierBitmask } from "./keyEvents.ts";
4043
import {
4144
buildAriaSnapshotScript,
@@ -110,13 +113,34 @@ interface AttachedTab {
110113
dispose: () => void;
111114
}
112115

116+
/**
117+
* The sites one guest's own pages may navigate to.
118+
*
119+
* Deliberately not part of `AttachedTab`: that record comes and goes with the
120+
* CDP attachment -- opening DevTools on the page is enough to drop it -- and a
121+
* navigation guard that lapsed whenever the debugger did would be no guard at
122+
* all. This one lives as long as the guest does.
123+
*/
124+
interface NavigationGuard {
125+
approvedDomains: ReadonlyArray<string>;
126+
}
127+
113128
export class PreviewAutomation extends Context.Service<
114129
PreviewAutomation,
115130
{
116131
readonly attach: (
117132
webContentsId: number,
118133
) => Effect.Effect<DesktopPreviewStatus, PreviewAutomationError>;
119134
readonly detach: (webContentsId: number) => Effect.Effect<void>;
135+
/**
136+
* Replaces the sites this guest's own pages may navigate to. Until it is
137+
* called the guest is private-network-only, so an unpushed policy refuses
138+
* rather than allows.
139+
*/
140+
readonly setNavigationPolicy: (
141+
webContentsId: number,
142+
approvedDomains: ReadonlyArray<string>,
143+
) => Effect.Effect<void, PreviewAutomationError>;
120144
readonly status: (
121145
webContentsId: number,
122146
) => Effect.Effect<DesktopPreviewStatus, PreviewAutomationError>;
@@ -203,6 +227,96 @@ export const make = Effect.sync(function PreviewAutomationMake() {
203227
/** Whoever is waiting for a drawing to be attached, so turning the mode off
204228
* can release them. */
205229
const drawWaiters = new Map<number, () => void>();
230+
const navigationGuards = new Map<number, NavigationGuard>();
231+
232+
/**
233+
* The site a navigation is aimed at, or null when it is not aimed at one.
234+
*
235+
* Only http(s) has a host to approve. `about:`, `blob:` and `data:` name no
236+
* site, and Chromium already refuses the top-level navigations among them
237+
* that would matter -- refusing them here as "unapproved host" would instead
238+
* break `about:blank`, which is how every empty tab starts.
239+
*/
240+
const navigationHost = (url: string): string | null => {
241+
try {
242+
const parsed = new URL(url);
243+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
244+
return null;
245+
}
246+
return parsed.hostname === "" ? null : parsed.hostname;
247+
} catch {
248+
return null;
249+
}
250+
};
251+
252+
/**
253+
* Tells the renderer a page tried to go somewhere it may not.
254+
*
255+
* Sent to the window embedding the guest, because that is the renderer that
256+
* owns the tab and can put the question in front of the user. Every window
257+
* is the fallback rather than silence: the notice is addressed by
258+
* webContents id, so a window that does not own this tab ignores it.
259+
*/
260+
const reportBlockedNavigation = (contents: WebContents, url: string, blockedHost: string) => {
261+
const payload: DesktopPreviewNavigationBlocked = {
262+
webContentsId: contents.id,
263+
host: blockedHost,
264+
url,
265+
};
266+
const embedder = contents.hostWebContents;
267+
if (embedder !== null && !embedder.isDestroyed()) {
268+
embedder.send(IpcChannels.PREVIEW_NAVIGATION_BLOCKED_CHANNEL, payload);
269+
return;
270+
}
271+
for (const window of BrowserWindow.getAllWindows()) {
272+
if (!window.isDestroyed()) {
273+
window.webContents.send(IpcChannels.PREVIEW_NAVIGATION_BLOCKED_CHANNEL, payload);
274+
}
275+
}
276+
};
277+
278+
/**
279+
* Arms the guest's own navigations against the guest's allowlist.
280+
*
281+
* Registered at attach as well as when a policy arrives, so a tab whose
282+
* policy has not been pushed yet is still held to private-network-only rather
283+
* than being left open until the renderer gets round to it.
284+
*/
285+
const ensureNavigationGuard = (contents: WebContents): NavigationGuard => {
286+
const existing = navigationGuards.get(contents.id);
287+
if (existing !== undefined) {
288+
return existing;
289+
}
290+
const guard: NavigationGuard = { approvedDomains: [] };
291+
const refuse = (details: {
292+
readonly url: string;
293+
readonly isMainFrame: boolean;
294+
readonly preventDefault: () => void;
295+
}) => {
296+
// Only the top-level document. An iframe is the page's own composition,
297+
// not somewhere the browser went.
298+
if (!details.isMainFrame) {
299+
return;
300+
}
301+
const blockedHost = navigationHost(details.url);
302+
if (blockedHost === null || isBrowserHostApproved(blockedHost, guard.approvedDomains)) {
303+
return;
304+
}
305+
details.preventDefault();
306+
reportBlockedNavigation(contents, details.url, blockedHost);
307+
};
308+
// Both, because a link click and a 302 arriving mid-load are the same
309+
// event to the user: the page ended up somewhere nobody approved.
310+
contents.on("will-navigate", refuse);
311+
contents.on("will-redirect", refuse);
312+
contents.once("destroyed", () => {
313+
contents.off("will-navigate", refuse);
314+
contents.off("will-redirect", refuse);
315+
navigationGuards.delete(contents.id);
316+
});
317+
navigationGuards.set(contents.id, guard);
318+
return guard;
319+
};
206320

207321
const resolve = (webContentsId: number) =>
208322
Effect.suspend(() => {
@@ -687,6 +801,9 @@ export const make = Effect.sync(function PreviewAutomationMake() {
687801
*/
688802
const attach = Effect.fn("PreviewAutomation.attach")(function* (webContentsId: number) {
689803
const contents = yield* resolve(webContentsId);
804+
// Before anything else, and outside the debugger's lifecycle: a guest that
805+
// exists is a guest that can be sent somewhere by its own page.
806+
ensureNavigationGuard(contents);
690807
if (attached.has(webContentsId) && contents.debugger.isAttached()) {
691808
return buildStatus(webContentsId, contents);
692809
}
@@ -899,6 +1016,13 @@ export const make = Effect.sync(function PreviewAutomationMake() {
8991016
tab.contents.debugger.detach();
9001017
}
9011018
}),
1019+
setNavigationPolicy: Effect.fn("PreviewAutomation.setNavigationPolicy")(function* (
1020+
webContentsId: number,
1021+
approvedDomains: ReadonlyArray<string>,
1022+
) {
1023+
const contents = yield* resolve(webContentsId);
1024+
ensureNavigationGuard(contents).approvedDomains = approvedDomains;
1025+
}),
9021026
status: Effect.fn("PreviewAutomation.status")(function* (webContentsId: number) {
9031027
const contents = yield* resolveAttached(webContentsId);
9041028
return buildStatus(webContentsId, contents);

apps/desktop/src/settings/DesktopClientSettings.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
1212
import * as DesktopClientSettings from "./DesktopClientSettings.ts";
1313

1414
const clientSettings: ClientSettings = {
15+
agentBrowserApprovedDomains: {},
1516
autoOpenPlanSidebar: false,
1617
chatChangedFilesDefaultExpanded: false,
1718
confirmThreadArchive: true,
@@ -32,6 +33,7 @@ const clientSettings: ClientSettings = {
3233
sidebarProjectSortOrder: "manual",
3334
sidebarThreadSortOrder: "created_at",
3435
sidebarThreadPreviewCount: 6,
36+
sourceControlPanelDefaultOpen: false,
3537
timestampFormat: "24-hour",
3638
};
3739

apps/server/src/cli/project.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -366,6 +366,9 @@ const projectRemoveCommand = Command.make("remove", {
366366
project: Argument.string("project").pipe(
367367
Argument.withDescription("Project id or workspace root to remove."),
368368
),
369+
force: Flag.boolean("force").pipe(
370+
Flag.withDescription("Delete the project even if it still has threads (deletes them too)."),
371+
),
369372
}).pipe(
370373
Command.withDescription("Remove a project."),
371374
Command.withHandler((flags) =>
@@ -388,6 +391,7 @@ const projectRemoveCommand = Command.make("remove", {
388391
type: "project.delete",
389392
commandId: CommandId.make(crypto.randomUUID()),
390393
projectId: project.id,
394+
...(flags.force ? { force: true } : {}),
391395
});
392396
return `Removed project ${project.id} (${project.title}).`;
393397
}),

0 commit comments

Comments
 (0)