diff --git a/apps/opencode-plugin/README.md b/apps/opencode-plugin/README.md index 6053ef1a..a348a2ff 100644 --- a/apps/opencode-plugin/README.md +++ b/apps/opencode-plugin/README.md @@ -60,6 +60,7 @@ Restart OpenCode. The `submit_plan` tool is now available. | `PLANNOTATOR_PORT` | Fixed port to use. Default: random locally, `19432` for remote sessions. | | `PLANNOTATOR_BROWSER` | Custom browser to open plans in. macOS: app name or path. Linux/Windows: executable path. | | `PLANNOTATOR_SHARE_URL` | Custom share portal URL for self-hosting. Default: `https://share.plannotator.ai`. | +| `PLANNOTATOR_PASTE_URL` | Custom paste service URL for self-hosting. Default: `https://plannotator-paste.plannotator.workers.dev`. | | `PLANNOTATOR_PLAN_TIMEOUT_SECONDS` | Timeout for `submit_plan` review wait. Default: `345600` (96h). Set `0` to disable timeout. | ## Devcontainer / Docker diff --git a/apps/opencode-plugin/commands.ts b/apps/opencode-plugin/commands.ts index 4f1350c2..1887952c 100644 --- a/apps/opencode-plugin/commands.ts +++ b/apps/opencode-plugin/commands.ts @@ -34,6 +34,7 @@ export interface CommandDeps { reviewHtmlContent: string; getSharingEnabled: () => Promise; getShareBaseUrl: () => string | undefined; + getPasteApiUrl: () => string | undefined; directory?: string; } @@ -147,7 +148,7 @@ export async function handleAnnotateCommand( event: any, deps: CommandDeps ) { - const { client, htmlContent, getSharingEnabled, getShareBaseUrl } = deps; + const { client, htmlContent, getSharingEnabled, getShareBaseUrl, getPasteApiUrl } = deps; // @ts-ignore - Event properties contain arguments const filePath = event.properties?.arguments || event.arguments || ""; @@ -228,6 +229,7 @@ export async function handleAnnotateCommand( sourceInfo, sharingEnabled: await getSharingEnabled(), shareBaseUrl: getShareBaseUrl(), + pasteApiUrl: getPasteApiUrl(), htmlContent, onReady: handleAnnotateServerReady, }); @@ -271,7 +273,7 @@ export async function handleAnnotateLastCommand( event: any, deps: CommandDeps ): Promise { - const { client, htmlContent, getSharingEnabled, getShareBaseUrl } = deps; + const { client, htmlContent, getSharingEnabled, getShareBaseUrl, getPasteApiUrl } = deps; // @ts-ignore - Event properties contain sessionID const sessionId = event.properties?.sessionID; @@ -317,6 +319,7 @@ export async function handleAnnotateLastCommand( mode: "annotate-last", sharingEnabled: await getSharingEnabled(), shareBaseUrl: getShareBaseUrl(), + pasteApiUrl: getPasteApiUrl(), htmlContent, onReady: handleAnnotateServerReady, }); @@ -336,7 +339,7 @@ export async function handleArchiveCommand( event: any, deps: CommandDeps ) { - const { client, htmlContent, getSharingEnabled, getShareBaseUrl } = deps; + const { client, htmlContent, getSharingEnabled, getShareBaseUrl, getPasteApiUrl } = deps; client.app.log({ level: "info", message: "Opening plan archive..." }); @@ -346,6 +349,7 @@ export async function handleArchiveCommand( mode: "archive", sharingEnabled: await getSharingEnabled(), shareBaseUrl: getShareBaseUrl(), + pasteApiUrl: getPasteApiUrl(), htmlContent, onReady: handleServerReady, }); diff --git a/apps/opencode-plugin/index.ts b/apps/opencode-plugin/index.ts index dad10732..84af6f03 100644 --- a/apps/opencode-plugin/index.ts +++ b/apps/opencode-plugin/index.ts @@ -195,6 +195,10 @@ export const PlannotatorPlugin: Plugin = async (ctx) => { return process.env.PLANNOTATOR_SHARE_URL || undefined; } + function getPasteApiUrl(): string | undefined { + return process.env.PLANNOTATOR_PASTE_URL || undefined; + } + function getPlanTimeoutSeconds(): number | null { const raw = process.env.PLANNOTATOR_PLAN_TIMEOUT_SECONDS?.trim(); if (!raw) return DEFAULT_PLAN_TIMEOUT_SECONDS; @@ -363,6 +367,7 @@ Do NOT proceed with implementation until your plan is approved.`); reviewHtmlContent: getReviewHtml(), getSharingEnabled, getShareBaseUrl, + getPasteApiUrl, directory: ctx.directory, }; @@ -405,6 +410,7 @@ Do NOT proceed with implementation until your plan is approved.`); reviewHtmlContent: getReviewHtml(), getSharingEnabled, getShareBaseUrl, + getPasteApiUrl, directory: ctx.directory, }; @@ -448,6 +454,7 @@ Do NOT proceed with implementation until your plan is approved.`); origin: "opencode", sharingEnabled, shareBaseUrl: getShareBaseUrl(), + pasteApiUrl: getPasteApiUrl(), htmlContent: getPlanHtml(), opencodeClient: ctx.client, onReady: async (url, isRemote, port) => { diff --git a/apps/paste-service/core/cors.ts b/apps/paste-service/core/cors.ts index 548fc8ee..445d90fb 100644 --- a/apps/paste-service/core/cors.ts +++ b/apps/paste-service/core/cors.ts @@ -4,6 +4,10 @@ const BASE_CORS_HEADERS = { "Access-Control-Max-Age": "86400", }; +// Defaults target the hosted plannotator.ai deployment. +// Self-hosters should set PASTE_ALLOWED_ORIGINS (Bun) or ALLOWED_ORIGINS (Cloudflare) +// to their own portal origin so requests from the hosted share.plannotator.ai +// portal are not granted CORS access against their service. export function getAllowedOrigins(envValue?: string): string[] { if (envValue) { return envValue.split(",").map((o) => o.trim()); diff --git a/apps/paste-service/wrangler.toml b/apps/paste-service/wrangler.toml index 7acdf56b..edc3098f 100644 --- a/apps/paste-service/wrangler.toml +++ b/apps/paste-service/wrangler.toml @@ -9,4 +9,7 @@ id = "9bc2647f6f5244499c26c90d87a743a0" preview_id = "6efae5ac33c4443ba8f0a0b83a2eb111" [vars] +# Default values target the hosted plannotator.ai deployment. +# Self-hosters must override ALLOWED_ORIGINS to point at their own portal, +# either by editing this file or setting it via `wrangler secret put`. ALLOWED_ORIGINS = "https://share.plannotator.ai,http://localhost:3001" diff --git a/apps/pi-extension/plannotator-browser.ts b/apps/pi-extension/plannotator-browser.ts index 8292c655..4032959f 100644 --- a/apps/pi-extension/plannotator-browser.ts +++ b/apps/pi-extension/plannotator-browser.ts @@ -356,6 +356,7 @@ export async function openCodeReview( htmlContent: reviewHtmlContent, sharingEnabled: process.env.PLANNOTATOR_SHARE !== "disabled", shareBaseUrl: process.env.PLANNOTATOR_SHARE_URL || undefined, + pasteApiUrl: process.env.PLANNOTATOR_PASTE_URL || undefined, onCleanup: worktreeCleanup, }); diff --git a/apps/pi-extension/server/serverReview.ts b/apps/pi-extension/server/serverReview.ts index c69180aa..6ffdf7d1 100644 --- a/apps/pi-extension/server/serverReview.ts +++ b/apps/pi-extension/server/serverReview.ts @@ -147,6 +147,7 @@ export async function startReviewServer(options: { error?: string; sharingEnabled?: boolean; shareBaseUrl?: string; + pasteApiUrl?: string; prMetadata?: PRMetadata; /** Working directory for agent processes (e.g., --local worktree). Independent of diff pipeline. */ agentCwd?: string; @@ -281,6 +282,8 @@ export async function startReviewServer(options: { options.sharingEnabled ?? process.env.PLANNOTATOR_SHARE !== "disabled"; const shareBaseUrl = (options.shareBaseUrl ?? process.env.PLANNOTATOR_SHARE_URL) || undefined; + const pasteApiUrl = + (options.pasteApiUrl ?? process.env.PLANNOTATOR_PASTE_URL) || undefined; let resolveDecision!: (result: { approved: boolean; feedback: string; @@ -421,6 +424,7 @@ export async function startReviewServer(options: { gitContext: hasLocalAccess ? options.gitContext : undefined, sharingEnabled, shareBaseUrl, + pasteApiUrl, repoInfo, isWSL: wslFlag, ...(options.agentCwd && { agentCwd: options.agentCwd }), diff --git a/packages/ui/components/Landing.tsx b/packages/ui/components/Landing.tsx index ca2b38e9..f01d7b16 100644 --- a/packages/ui/components/Landing.tsx +++ b/packages/ui/components/Landing.tsx @@ -4,9 +4,11 @@ import { ModeToggle } from "./ModeToggle"; interface LandingProps { onEnter?: () => void; + shareBaseUrl?: string; } -export const Landing: React.FC = ({ onEnter }) => { +export const Landing: React.FC = ({ onEnter, shareBaseUrl }) => { + const demoUrl = shareBaseUrl || "https://share.plannotator.ai"; return (
{/* Nav */} @@ -112,7 +114,7 @@ export const Landing: React.FC = ({ onEnter }) => { ) : ( Open Demo diff --git a/packages/ui/hooks/useSharing.ts b/packages/ui/hooks/useSharing.ts index 28b98fbd..58dc2d67 100644 --- a/packages/ui/hooks/useSharing.ts +++ b/packages/ui/hooks/useSharing.ts @@ -114,11 +114,15 @@ export function useSharing( if (pathMatch) { const pasteId = pathMatch[1]; - // Extract encryption key from URL fragment: #key= + // Extract key and optional paste origin from fragment: #key=&paste= const fragment = window.location.hash.slice(1); - const encryptionKey = fragment.startsWith('key=') ? fragment.slice(4) : undefined; + const params = new URLSearchParams(fragment); + const encryptionKey = params.get('key') ?? undefined; + const pasteFromFragment = params.get('paste') + ? atob(params.get('paste')!.replace(/-/g, '+').replace(/_/g, '/')) + : undefined; - const payload = await loadFromPasteId(pasteId, pasteApiUrl, encryptionKey); + const payload = await loadFromPasteId(pasteId, pasteFromFragment ?? pasteApiUrl, encryptionKey); if (payload) { setMarkdown(payload.p); @@ -279,11 +283,15 @@ export function useSharing( let payload: SharePayload | undefined; // Check for short URL pattern: /p/ with optional #key= fragment - const shortMatch = url.match(/\/p\/([A-Za-z0-9]{6,16})(?:#key=([A-Za-z0-9_-]+))?(?:\?|#|$)/); + const shortMatch = url.match(/\/p\/([A-Za-z0-9]{6,16})(?:#(.*))?(?:\?|$)/); if (shortMatch) { const pasteId = shortMatch[1]; - const encryptionKey = shortMatch[2]; // undefined if no key fragment - const loaded = await loadFromPasteId(pasteId, pasteApiUrl, encryptionKey); + const fragParams = new URLSearchParams(shortMatch[2] ?? ''); + const encryptionKey = fragParams.get('key') ?? undefined; + const pasteFromFragment = fragParams.get('paste') + ? atob(fragParams.get('paste')!.replace(/-/g, '+').replace(/_/g, '/')) + : undefined; + const loaded = await loadFromPasteId(pasteId, pasteFromFragment ?? pasteApiUrl, encryptionKey); if (!loaded) { return { success: false, count: 0, planTitle: '', error: 'Failed to load from short URL — paste may have expired' }; } diff --git a/packages/ui/utils/sharing.ts b/packages/ui/utils/sharing.ts index 1707fda0..714f884c 100644 --- a/packages/ui/utils/sharing.ts +++ b/packages/ui/utils/sharing.ts @@ -267,8 +267,12 @@ export async function createShortShareUrl( } const result = (await response.json()) as { id: string }; - // Key in fragment — never sent to server per HTTP spec - const shortUrl = `${shareBase}/p/${result.id}#key=${key}`; + // Embed paste origin in fragment when non-default so the share portal can + // fetch from the right service without a server. + const pasteParam = pasteApi !== DEFAULT_PASTE_API + ? `&paste=${btoa(pasteApi).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '')}` + : ''; + const shortUrl = `${shareBase}/p/${result.id}#key=${key}${pasteParam}`; return { shortUrl, id: result.id }; } catch (e) {