History

Fix OAuth callback org scope (#1134)

* Fix OAuth callback org scope * Show URLs in e2e PR media * Record OAuth org switch flow

Rhys Sullivan committed 4 months ago

Commit 78aa8711 parent 913c8c214 files · +586 −15Browse files

Unified · Split

14 files
  1. .changeset/oauth-callback-org-scope.md +5 −0
  2. apps/cloud/src/auth/organization.ts +2 −1
  3. apps/cloud/src/start.ts +15 −1
  4. e2e/cloud/oauth-callback-org-scope.test.ts +265 −0
  5. e2e/scripts/pr-media.ts +210 −8
  6. packages/core/api/src/server/oauth-origin.test.ts +10 −0
  7. packages/core/api/src/server/scoped-executor.ts +10 −2
  8. packages/core/sdk/src/index.ts +6 −1
  9. packages/core/sdk/src/oauth.ts +3 −0
  10. packages/core/sdk/src/server-connection.ts +1 −0
  11. packages/core/sdk/src/shared.ts +3 −0
  12. packages/react/src/api/server-connection.tsx +3 −1
  13. packages/react/src/plugins/oauth-sign-in.test.ts +46 −0
  14. packages/react/src/plugins/oauth-sign-in.tsx +7 −1
.changeset/oauth-callback-org-scope.md+5−0
@@ -0,0 +1,5 @@
1+---
2+"executor": patch
3+---
4+
5+Fix OAuth callbacks in cloud so they preserve the URL-selected organization when the session cookie points at another org.
apps/cloud/src/auth/organization.ts+2−1
@@ -12,6 +12,7 @@ · 11 unmodified lines
1212 // ---------------------------------------------------------------------------
1313
1414 import { Effect } from "effect";
15+import { EXECUTOR_ORG_SELECTOR_HEADER } from "@executor-js/sdk/shared";
1516
1617 import { UserStoreService } from "./context";
1718 import { WorkOSClient } from "./workos";
@@ -96,7 +97,7 @@ export const authorizeOrganization = (userId: string, organizationId: string) => · 78 unmodified lines
9697 // silently re-scopes the other. Scoping per-request from the URL makes each
9798 // tab independent.
9899
99−export const ORG_SELECTOR_HEADER = "x-executor-organization";
100+export const ORG_SELECTOR_HEADER = EXECUTOR_ORG_SELECTOR_HEADER;
100101
101102 /** The URL-pinned org selector for a request, or `null` to fall back to the session. */
102103 export const orgSelectorFromRequest = (request: Request): string | null =>
apps/cloud/src/start.ts+15−1
@@ -1,9 +1,11 @@
11 import { createMiddleware, createStart } from "@tanstack/react-start";
2+import { OAUTH_CALLBACK_ORG_QUERY_PARAM } from "@executor-js/sdk/shared";
23
34 import { cloudApiHandler } from "./app";
45 import { isAppOwnedPath } from "./app-paths";
56 import { authGateMiddleware } from "./auth/ssr-gate";
67 import { parseCookie } from "./auth/cookies";
8+import { ORG_SELECTOR_HEADER } from "./auth/organization";
79 import { loginPath } from "./auth/return-to";
810 import { prepareMcpOrgScope } from "./mcp/mount";
911 import {
@@ -39,6 +41,14 @@ const getApp = () => (app ??= cloudApiHandler()); · 29 unmodified lines
3941 const SESSION_COOKIE = "wos-session";
4042 const OAUTH_CALLBACK_PATH = "/api/oauth/callback";
4143
44+const oauthCallbackOrgScopedRequest = (request: Request): Request => {
45+ const orgSelector = new URL(request.url).searchParams.get(OAUTH_CALLBACK_ORG_QUERY_PARAM);
46+ if (!orgSelector) return request;
47+ const headers = new Headers(request.headers);
48+ headers.set(ORG_SELECTOR_HEADER, orgSelector);
49+ return new Request(request, { headers });
50+};
51+
4252 const oauthCallbackSignInMiddleware = createMiddleware({ type: "request" }).server(
4353 ({ pathname, request, next }) => {
4454 if (
@@ -66,7 +76,11 @@ const oauthCallbackSignInMiddleware = createMiddleware({ type: "request" }).serv · 21 unmodified lines
6676 // else, including `/api/*`).
6777 const appRequestMiddleware = createMiddleware({ type: "request" }).server(
6878 ({ pathname, request, next }) => {
69− if (isAppOwnedPath(pathname)) return getApp().handler(prepareMcpOrgScope(request));
79+ if (isAppOwnedPath(pathname)) {
80+ const scopedRequest =
81+ pathname === OAUTH_CALLBACK_PATH ? oauthCallbackOrgScopedRequest(request) : request;
82+ return getApp().handler(prepareMcpOrgScope(scopedRequest));
83+ }
7084 return next();
7185 },
7286 );
e2e/cloud/oauth-callback-org-scope.test.ts+265−0
@@ -0,0 +1,265 @@
1+// Cloud-only: OAuth callbacks must preserve the URL-selected organization.
2+//
3+// A browser session cookie can be pinned to org B while a tab is operating in
4+// org A via the URL org selector. OAuth redirects leave the console route and
5+// land on /api/oauth/callback, so the callback URL itself must carry the same
6+// org selector that was present when oauth.start created the session.
7+import { randomBytes } from "node:crypto";
8+
9+import { expect } from "@effect/vitest";
10+import { Effect } from "effect";
11+import type { Page } from "playwright";
12+import { composePluginApi } from "@executor-js/api/server";
13+import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
14+import {
15+ IntegrationSlug,
16+ OAUTH_CALLBACK_ORG_QUERY_PARAM,
17+ OAuthClientSlug,
18+} from "@executor-js/sdk/shared";
19+import { serveOAuthTestServer } from "@executor-js/sdk/testing";
20+
21+import { scenario } from "../src/scenario";
22+import { Api, Browser, Target } from "../src/services";
23+import type { Identity } from "../src/target";
24+
25+const api = composePluginApi([openApiHttpPlugin()] as const);
26+
27+const unique = (prefix: string) => `${prefix}_${randomBytes(4).toString("hex")}`;
28+
29+const cookiePair = (response: Response, name: string): string | undefined => {
30+ for (const header of response.headers.getSetCookie?.() ?? []) {
31+ if (header.startsWith(`${name}=`)) return header.split(";")[0];
32+ }
33+ return undefined;
34+};
35+
36+const cookieValue = (pair: string): string => {
37+ const [, value] = pair.split(/=(.*)/s);
38+ if (!value) throw new Error("cookie pair has no value");
39+ return value;
40+};
41+
42+const cookieOf = (identity: Identity): string => identity.headers?.cookie ?? "";
43+
44+const originHeaders = (baseUrl: string) => ({ origin: new URL(baseUrl).origin });
45+
46+const escapeRegExp = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
47+
48+const setWorkosSessionCookie = async (page: Page, baseUrl: string, cookie: string) => {
49+ await page.context().addCookies([
50+ {
51+ name: "wos-session",
52+ value: cookieValue(cookie),
53+ url: baseUrl,
54+ },
55+ ]);
56+};
57+
58+const expectOrgShell = async (
59+ page: Page,
60+ org: { readonly name: string; readonly slug: string },
61+) => {
62+ await page.waitForURL(
63+ (url) => url.pathname === `/${org.slug}` || url.pathname === `/${org.slug}/`,
64+ {
65+ timeout: 30_000,
66+ },
67+ );
68+ await page.getByRole("button", { name: new RegExp(escapeRegExp(org.name)) }).waitFor();
69+ await page.getByRole("heading", { name: "Integrations" }).waitFor();
70+};
71+
72+const installSameWindowOAuthPopup = async (page: Page) => {
73+ await page.addInitScript(() => {
74+ window.open = () =>
75+ ({
76+ get closed() {
77+ return false;
78+ },
79+ close() {},
80+ focus() {},
81+ location: {
82+ get href() {
83+ return window.location.href;
84+ },
85+ set href(value: string) {
86+ window.location.assign(String(value));
87+ },
88+ },
89+ }) as Window;
90+ });
91+};
92+
93+const submitProviderLoginFromPage = async (page: Page): Promise<string> =>
94+ fetch(page.url(), {
95+ method: "POST",
96+ redirect: "manual",
97+ headers: {
98+ authorization: `Basic ${Buffer.from("alice:password").toString("base64")}`,
99+ },
100+ }).then((response) => {
101+ const location = response.headers.get("location");
102+ if (response.status !== 302 || !location) {
103+ throw new Error(`provider did not return callback location (${response.status})`);
104+ }
105+ return new URL(location, page.url()).toString();
106+ });
107+
108+const activeOrg = (baseUrl: string, cookie: string) =>
109+ Effect.promise(async () => {
110+ const response = await fetch(new URL("/api/auth/me", baseUrl), {
111+ headers: { cookie },
112+ });
113+ if (!response.ok) throw new Error(`/api/auth/me failed (${response.status})`);
114+ const body = (await response.json()) as {
115+ organization: { id: string; name: string; slug: string } | null;
116+ };
117+ if (!body.organization) throw new Error("identity has no active organization");
118+ return body.organization;
119+ });
120+
121+const createOrganization = (baseUrl: string, cookie: string, name: string) =>
122+ Effect.promise(async () => {
123+ const response = await fetch(new URL("/api/auth/create-organization", baseUrl), {
124+ method: "POST",
125+ headers: {
126+ "content-type": "application/json",
127+ cookie,
128+ ...originHeaders(baseUrl),
129+ },
130+ body: JSON.stringify({ name }),
131+ });
132+ if (!response.ok) {
133+ throw new Error(`/api/auth/create-organization failed (${response.status})`);
134+ }
135+ const session = cookiePair(response, "wos-session");
136+ if (!session) throw new Error("create organization did not refresh the session");
137+ const org = (await response.json()) as { id: string; name: string; slug: string };
138+ return { org, session };
139+ });
140+
141+const oauthIntegrationSpec = (oauth: {
142+ readonly authorizationEndpoint: string;
143+ readonly tokenEndpoint: string;
144+}) =>
145+ ({
146+ spec: {
147+ kind: "blob" as const,
148+ value: JSON.stringify({
149+ openapi: "3.0.3",
150+ info: { title: "OAuth org scope", version: "1.0.0" },
151+ paths: {
152+ "/me": {
153+ get: {
154+ operationId: "getMe",
155+ responses: { "200": { description: "the caller" } },
156+ },
157+ },
158+ },
159+ }),
160+ },
161+ baseUrl: "http://127.0.0.1:59999",
162+ authenticationTemplate: [
163+ {
164+ slug: "oauth",
165+ kind: "oauth2" as const,
166+ authorizationUrl: oauth.authorizationEndpoint,
167+ tokenUrl: oauth.tokenEndpoint,
168+ scopes: ["read"],
169+ },
170+ ],
171+ }) as const;
172+
173+scenario(
174+ "OAuth callback · URL-scoped org survives a callback while the session cookie points elsewhere",
175+ {},
176+ Effect.gen(function* () {
177+ const target = yield* Target;
178+ const { client: makeApiClient } = yield* Api;
179+ const browser = yield* Browser;
180+ const oauth = yield* serveOAuthTestServer();
181+
182+ const identity = yield* target.newIdentity();
183+ const sessionA = cookieOf(identity);
184+ const orgA = yield* activeOrg(target.baseUrl, sessionA);
185+
186+ const { org: orgB, session: sessionB } = yield* createOrganization(
187+ target.baseUrl,
188+ sessionA,
189+ `OAuth Callback Org B ${randomBytes(3).toString("hex")}`,
190+ );
191+ expect(orgB.slug, "the test has two distinct org URLs").not.toBe(orgA.slug);
192+
193+ const client = yield* makeApiClient(api, identity);
194+
195+ const integration = IntegrationSlug.make(unique("oauthscope"));
196+ yield* client.openapi.addSpec({
197+ payload: { ...oauthIntegrationSpec(oauth), slug: integration },
198+ });
199+
200+ const clientSlug = OAuthClientSlug.make(unique("oauthc"));
201+ yield* client.oauth.createClient({
202+ payload: {
203+ owner: "org",
204+ slug: clientSlug,
205+ authorizationUrl: oauth.authorizationEndpoint,
206+ tokenUrl: oauth.tokenEndpoint,
207+ grant: "authorization_code",
208+ clientId: "test-client",
209+ clientSecret: "test-secret",
210+ },
211+ });
212+
213+ let callback = new URL("http://invalid.example");
214+ yield* browser.session(identity, async ({ page, step }) => {
215+ await installSameWindowOAuthPopup(page);
216+
217+ await step("Land in the original organization", async () => {
218+ await page.goto(`/${orgA.slug}`, { waitUntil: "networkidle" });
219+ await expectOrgShell(page, orgA);
220+ });
221+
222+ await step("The browser session is switched to another organization", async () => {
223+ await setWorkosSessionCookie(page, target.baseUrl, sessionB);
224+ await page.goto(`/${orgB.slug}`, { waitUntil: "networkidle" });
225+ await expectOrgShell(page, orgB);
226+ });
227+
228+ await step("Start OAuth from the original organization's add-connection flow", async () => {
229+ await page.goto(`/${orgA.slug}/integrations/${String(integration)}?addAccount=1`, {
230+ waitUntil: "networkidle",
231+ });
232+ await page.getByRole("heading", { name: /Add connection/ }).waitFor({
233+ timeout: 30_000,
234+ });
235+ await page.getByRole("button", { name: "Connect with OAuth" }).click();
236+ await page.waitForURL(
237+ (url) => url.origin === new URL(oauth.issuerUrl).origin && url.pathname === "/login",
238+ { timeout: 30_000 },
239+ );
240+ await page.getByText("OAuth test login").waitFor();
241+ });
242+
243+ await step("The provider returns to the OAuth callback", async () => {
244+ const callbackUrl = await submitProviderLoginFromPage(page);
245+ callback = new URL(callbackUrl);
246+ const response = await page.goto(callbackUrl, { waitUntil: "networkidle" });
247+ expect(response?.status(), "the callback renders its popup result page").toBe(200);
248+ });
249+
250+ const body = (await page.locator("body").textContent())?.trim() ?? "";
251+ expect(
252+ body,
253+ "the callback completes in the org where oauth.start stored the session",
254+ ).toContain("Connected");
255+ expect(body, "the callback did not fall through to the cookie-pinned org").not.toContain(
256+ "OAuth session expired or not found",
257+ );
258+ });
259+
260+ expect(
261+ callback.searchParams.get(OAUTH_CALLBACK_ORG_QUERY_PARAM),
262+ "the provider callback URL carries the original org selector",
263+ ).toBe(orgA.slug);
264+ }).pipe(Effect.scoped),
265+);
e2e/scripts/pr-media.ts+210−8
@@ -16,13 +16,17 @@ · 15 unmodified lines
1616 // *.png run screenshot, uploaded as-is
1717 import { execFileSync } from "node:child_process";
1818 import { createHash } from "node:crypto";
19−import { existsSync, readFileSync, statSync, writeFileSync } from "node:fs";
19+import { existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
2020 import { tmpdir } from "node:os";
2121 import { basename, join, resolve } from "node:path";
2222
23+import { chromium } from "playwright";
24+
2325 const MEDIA_BRANCH = "e2e-media";
2426 // Above this GitHub's image proxy stops rendering the gif inline.
2527 const RENDER_LIMIT_BYTES = 10 * 1024 * 1024;
28+const GIF_WIDTH = 960;
29+const CHROME_HEIGHT = 40;
2630
2731 const run = (command: string, args: ReadonlyArray<string>): string =>
2832 execFileSync(command, [...args], {
@@ -36,6 +40,18 @@ interface Artifact { · 7 unmodified lines
3640 readonly path: string;
3741 readonly label: string;
3842 readonly slug: string;
43+ readonly runDir?: string;
44+}
45+
46+interface Timeline {
47+ readonly anchors: { readonly browser?: number };
48+ readonly nav?: ReadonlyArray<{ readonly at: number; readonly url: string }>;
49+}
50+
51+interface UrlSegment {
52+ readonly from: number;
53+ readonly to: number;
54+ readonly url: string;
3955 }
4056
4157 /** A run dir resolves to its recording; a file stands for itself. */
@@ -66,20 +82,206 @@ const resolveArtifact = (input: string): Artifact => { · 24 unmodified lines
6682 } catch {
6783 // skipped/partial runs still have a recording worth showing
6884 }
69− return { path: recording, label, slug: basename(path) };
85+ return { path: recording, label, slug: basename(path), runDir: path };
86+};
87+
88+const videoDuration = (file: string): number =>
89+ Number(
90+ run("ffprobe", [
91+ ...["-v", "quiet", "-show_entries", "format=duration"],
92+ ...["-of", "csv=p=0", file],
93+ ]).trim(),
94+ );
95+
96+const readTimeline = (artifact: Artifact): Timeline | null => {
97+ if (!artifact.runDir) return null;
98+ const timelinePath = join(artifact.runDir, "timeline.json");
99+ if (!existsSync(timelinePath)) return null;
100+ return JSON.parse(readFileSync(timelinePath, "utf8")) as Timeline;
101+};
102+
103+const urlSegments = (artifact: Artifact, duration: number): ReadonlyArray<UrlSegment> => {
104+ if (basename(artifact.path) !== "session.mp4") return [];
105+ const timeline = readTimeline(artifact);
106+ const browserAnchor = timeline?.anchors.browser;
107+ if (!timeline || browserAnchor === undefined) return [];
108+
109+ const nav = [...(timeline.nav ?? [])]
110+ .map((entry) => ({
111+ at: Math.max(0, (entry.at - browserAnchor) / 1000),
112+ url: entry.url,
113+ }))
114+ .filter((entry) => entry.at <= duration)
115+ .sort((a, b) => a.at - b.at);
116+
117+ if (nav.length === 0) return [{ from: 0, to: duration, url: "about:blank" }];
118+ if ((nav[0]?.at ?? 0) <= 0.25) nav[0] = { ...nav[0], at: 0 };
119+
120+ const segments: UrlSegment[] = [];
121+ let cursor = 0;
122+ let currentUrl = "about:blank";
123+ for (const entry of nav) {
124+ if (entry.at > cursor) segments.push({ from: cursor, to: entry.at, url: currentUrl });
125+ currentUrl = entry.url;
126+ cursor = entry.at;
127+ }
128+ segments.push({ from: cursor, to: duration, url: currentUrl });
129+
130+ return segments
131+ .filter((segment) => segment.to - segment.from > 0.01)
132+ .reduce<UrlSegment[]>((merged, segment) => {
133+ const previous = merged.at(-1);
134+ if (previous?.url === segment.url && Math.abs(previous.to - segment.from) < 0.01) {
135+ merged[merged.length - 1] = { ...previous, to: segment.to };
136+ } else {
137+ merged.push(segment);
138+ }
139+ return merged;
140+ }, []);
141+};
142+
143+const htmlEscape = (value: string): string =>
144+ value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
145+
146+const renderUrlBars = async (
147+ segments: ReadonlyArray<UrlSegment>,
148+ workDir: string,
149+): Promise<ReadonlyArray<{ readonly path: string; readonly segment: UrlSegment }>> => {
150+ const browser = await chromium.launch();
151+ try {
152+ const page = await browser.newPage({
153+ viewport: { width: GIF_WIDTH, height: CHROME_HEIGHT },
154+ deviceScaleFactor: 1,
155+ });
156+ const bars: Array<{ path: string; segment: UrlSegment }> = [];
157+ for (const [index, segment] of segments.entries()) {
158+ const displayUrl = segment.url.replace(/^https?:\/\//, "") || "about:blank";
159+ await page.setContent(`<!doctype html>
160+<html>
161+ <head>
162+ <style>
163+ * { box-sizing: border-box; }
164+ body {
165+ margin: 0;
166+ width: ${GIF_WIDTH}px;
167+ height: ${CHROME_HEIGHT}px;
168+ background: #111318;
169+ color: #d7dde8;
170+ font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
171+ }
172+ .chrome {
173+ height: ${CHROME_HEIGHT}px;
174+ display: flex;
175+ align-items: center;
176+ gap: 10px;
177+ padding: 7px 12px;
178+ border-bottom: 1px solid #272c36;
179+ }
180+ .lights {
181+ display: flex;
182+ gap: 6px;
183+ flex: 0 0 auto;
184+ }
185+ .lights i {
186+ width: 10px;
187+ height: 10px;
188+ border-radius: 50%;
189+ display: block;
190+ }
191+ .lights i:nth-child(1) { background: #ff5f56; }
192+ .lights i:nth-child(2) { background: #ffbd2e; }
193+ .lights i:nth-child(3) { background: #27c93f; }
194+ .urlbar {
195+ min-width: 0;
196+ flex: 1 1 auto;
197+ height: 26px;
198+ display: flex;
199+ align-items: center;
200+ border: 1px solid #313846;
201+ border-radius: 6px;
202+ background: #0b0d12;
203+ padding: 0 10px;
204+ color: #d7dde8;
205+ font-size: 12px;
206+ line-height: 1;
207+ white-space: nowrap;
208+ overflow: hidden;
209+ text-overflow: ellipsis;
210+ }
211+ .lock {
212+ color: #8b95a7;
213+ margin-right: 7px;
214+ flex: 0 0 auto;
215+ }
216+ </style>
217+ </head>
218+ <body>
219+ <div class="chrome">
220+ <span class="lights"><i></i><i></i><i></i></span>
221+ <div class="urlbar"><span class="lock">url</span>${htmlEscape(displayUrl)}</div>
222+ </div>
223+ </body>
224+</html>`);
225+ const path = join(workDir, `urlbar-${index}.png`);
226+ await page.screenshot({ path, animations: "disabled" });
227+ bars.push({ path, segment });
228+ }
229+ return bars;
230+ } finally {
231+ await browser.close();
232+ }
70233 };
71234
72−const toGif = (artifact: Artifact): string => {
235+const toGif = async (artifact: Artifact): Promise<string> => {
73236 if (artifact.path.endsWith(".png")) return artifact.path;
74237 const out = join(tmpdir(), `${artifact.slug}.gif`);
75238 if (artifact.path.endsWith(".cast")) {
76239 run("agg", ["--idle-time-limit", "2", "--font-size", "14", artifact.path, out]);
77240 } else {
78241 // Two-pass palette keeps UI text crisp at a size GitHub will render.
79− const filter =
80− "fps=8,scale=960:-2:flags=lanczos,split[a][b];" +
81− "[a]palettegen=max_colors=128[p];[b][p]paletteuse=dither=bayer:bayer_scale=5";
82− run("ffmpeg", ["-y", "-loglevel", "error", "-i", artifact.path, "-vf", filter, out]);
242+ const duration = videoDuration(artifact.path);
243+ const segments = urlSegments(artifact, duration);
244+ if (segments.length > 0) {
245+ const workDir = mkdtempSync(join(tmpdir(), "pr-media-urlbar-"));
246+ try {
247+ const bars = await renderUrlBars(segments, workDir);
248+ const scaled =
249+ `[0:v]fps=8,scale=${GIF_WIDTH}:-2:flags=lanczos,` +
250+ `pad=iw:ih+${CHROME_HEIGHT}:0:${CHROME_HEIGHT}:color=0x111318,setsar=1[v0]`;
251+ const overlays = bars.map(({ segment }, index) => {
252+ const input = index + 1;
253+ const previous = `v${index}`;
254+ const next = `v${index + 1}`;
255+ const from = segment.from.toFixed(3);
256+ const to = segment.to.toFixed(3);
257+ return `[${previous}][${input}:v]overlay=0:0:enable='between(t,${from},${to})'[${next}]`;
258+ });
259+ const finalVideo = `v${bars.length}`;
260+ const palette =
261+ `[${finalVideo}]split[a][b];` +
262+ "[a]palettegen=max_colors=128[p];[b][p]paletteuse=dither=bayer:bayer_scale=5[out]";
263+ run("ffmpeg", [
264+ "-y",
265+ "-loglevel",
266+ "error",
267+ "-i",
268+ artifact.path,
269+ ...bars.flatMap((bar) => ["-i", bar.path]),
270+ "-filter_complex",
271+ [scaled, ...overlays, palette].join(";"),
272+ "-map",
273+ "[out]",
274+ out,
275+ ]);
276+ } finally {
277+ rmSync(workDir, { recursive: true, force: true });
278+ }
279+ } else {
280+ const filter =
281+ `fps=8,scale=${GIF_WIDTH}:-2:flags=lanczos,split[a][b];` +
282+ "[a]palettegen=max_colors=128[p];[b][p]paletteuse=dither=bayer:bayer_scale=5";
283+ run("ffmpeg", ["-y", "-loglevel", "error", "-i", artifact.path, "-vf", filter, out]);
284+ }
83285 }
84286 return out;
85287 };
@@ -152,7 +354,7 @@ const repo = run("gh", ["repo", "view", "--json", "nameWithOwner", "-q", ".nameW · 66 unmodified lines
152354 const blocks: Array<string> = [];
153355 for (const input of inputs) {
154356 const artifact = resolveArtifact(input);
155− const gif = toGif(artifact);
357+ const gif = await toGif(artifact);
156358 const size = statSync(gif).size;
157359 if (size > RENDER_LIMIT_BYTES) {
158360 console.error(
packages/core/api/src/server/oauth-origin.test.ts+10−0
@@ -48,4 +48,14 @@ describe("OAuth web origin resolution", () => { · 47 unmodified lines
4848 }),
4949 ).toBe("https://executor.sh/api/oauth/callback");
5050 });
51+
52+ it("carries the URL org selector in OAuth redirect URLs", () => {
53+ expect(
54+ buildOAuthRedirectUri({
55+ webBaseUrl: "https://executor.sh",
56+ oauthCallbackPath: "/api/oauth/callback",
57+ orgSlug: "acme",
58+ }),
59+ ).toBe("https://executor.sh/api/oauth/callback?executor_org=acme");
60+ });
5161 });
packages/core/api/src/server/scoped-executor.ts+10−2
@@ -35,6 +35,7 @@ import { Context, Effect, Option } from "effect"; · 34 unmodified lines
3535
3636 import {
3737 createExecutor,
38+ OAUTH_CALLBACK_ORG_QUERY_PARAM,
3839 Subject,
3940 Tenant,
4041 type AnyPlugin,
@@ -151,8 +152,14 @@ export const resolveScopedWebBaseUrl = (input: { · 110 unmodified lines
151152 export const buildOAuthRedirectUri = (input: {
152153 readonly webBaseUrl: string | undefined;
153154 readonly oauthCallbackPath: string;
154−}): string | undefined =>
155− input.webBaseUrl ? new URL(input.oauthCallbackPath, input.webBaseUrl).toString() : undefined;
155+ readonly orgSlug?: string | null;
156+}): string | undefined => {
157+ if (!input.webBaseUrl) return undefined;
158+ const url = new URL(input.oauthCallbackPath, input.webBaseUrl);
159+ const slug = input.orgSlug?.trim();
160+ if (slug) url.searchParams.set(OAUTH_CALLBACK_ORG_QUERY_PARAM, slug);
161+ return url.toString();
162+};
156163
157164 // ---------------------------------------------------------------------------
158165 // PluginsProvider seam — the per-host (and possibly per-request) plugin array.
@@ -238,6 +245,7 @@ export const makeScopedExecutor = < · 79 unmodified lines
238245 const redirectUri = buildOAuthRedirectUri({
239246 webBaseUrl,
240247 oauthCallbackPath: config.oauthCallbackPath,
248+ orgSlug,
241249 });
242250
243251 const plugins = pluginsFactory();
packages/core/sdk/src/index.ts+6−1
@@ -214,7 +214,11 @@ export { · 213 unmodified lines
214214 } from "./plugin-storage";
215215
216216 // OAuth (v2 contracts).
217−export { OAUTH2_PROVIDER_KEY, OAUTH2_SESSION_TTL_MS } from "./oauth";
217+export {
218+ OAUTH2_PROVIDER_KEY,
219+ OAUTH2_SESSION_TTL_MS,
220+ OAUTH_CALLBACK_ORG_QUERY_PARAM,
221+} from "./oauth";
218222 export {
219223 OAuthStartError,
220224 OAuthCompleteError,
@@ -243,6 +247,7 @@ export { · 22 unmodified lines
243247 export {
244248 DEFAULT_EXECUTOR_SERVER_ORIGIN,
245249 DEFAULT_EXECUTOR_SERVER_USERNAME,
250+ EXECUTOR_ORG_SELECTOR_HEADER,
246251 apiBaseUrlForServerOrigin,
247252 getExecutorServerAuthorizationHeader,
248253 normalizeExecutorServerConnection,
packages/core/sdk/src/oauth.ts+3−0
@@ -35,3 +35,6 @@ export const OAUTH2_PROVIDER_KEY = "oauth2" as const; · 34 unmodified lines
3535
3636 /** How long a pending authorization stays redeemable. */
3737 export const OAUTH2_SESSION_TTL_MS = 15 * 60 * 1000;
38+
39+/** Query parameter used to carry the URL-selected org through OAuth callbacks. */
40+export const OAUTH_CALLBACK_ORG_QUERY_PARAM = "executor_org" as const;
packages/core/sdk/src/server-connection.ts+1−0
@@ -2,6 +2,7 @@ import { Option, Schema } from "effect"; · 1 unmodified line
22
33 export const DEFAULT_EXECUTOR_SERVER_ORIGIN = "http://127.0.0.1:4000";
44 export const DEFAULT_EXECUTOR_SERVER_USERNAME = "executor";
5+export const EXECUTOR_ORG_SELECTOR_HEADER = "x-executor-organization";
56
67 export type ExecutorServerConnectionKind = "http" | "desktop-sidecar";
78 export type ExecutorLocalServerKind = "cli-daemon" | "desktop-sidecar" | "foreground";
packages/core/sdk/src/shared.ts+3−0
@@ -99,6 +99,8 @@ export type { ToolPolicyAction } from "./core-schema"; · 98 unmodified lines
9999 // Schema-side views + onboarding autodetect.
100100 export { ToolSchemaView, IntegrationDetectionResult } from "./types";
101101
102+export { OAUTH_CALLBACK_ORG_QUERY_PARAM } from "./oauth";
103+
102104 // OAuth wire contracts (data + tagged errors; the flow impl is server-only).
103105 export {
104106 type OAuthGrant,
@@ -127,6 +129,7 @@ export { InternalError } from "./api-errors"; · 22 unmodified lines
127129 export {
128130 DEFAULT_EXECUTOR_SERVER_ORIGIN,
129131 DEFAULT_EXECUTOR_SERVER_USERNAME,
132+ EXECUTOR_ORG_SELECTOR_HEADER,
130133 apiBaseUrlForServerOrigin,
131134 getExecutorServerAuthorizationHeader,
132135 normalizeExecutorServerConnection,
packages/react/src/api/server-connection.tsx+3−1
@@ -2,6 +2,7 @@ import * as React from "react"; · 1 unmodified line
22 import { isValidOrgSlug } from "@executor-js/api";
33 import {
44 DEFAULT_EXECUTOR_SERVER_ORIGIN,
5+ EXECUTOR_ORG_SELECTOR_HEADER,
56 getExecutorServerAuthorizationHeader as getAuthorizationHeaderForConnection,
67 normalizeExecutorServerConnection,
78 originFromApiBaseUrl,
@@ -11,6 +12,7 @@ import { · 3 unmodified lines
1112
1213 export {
1314 DEFAULT_EXECUTOR_SERVER_ORIGIN,
15+ EXECUTOR_ORG_SELECTOR_HEADER,
1416 apiBaseUrlForServerOrigin,
1517 normalizeExecutorServerConnection,
1618 normalizeExecutorServerOrigin,
@@ -87,7 +89,7 @@ let activeConnection = resolveInitialExecutorServerConnection(); · 70 unmodified lines
8789 // `/login`, …) are NOT valid slugs, so a bare path sends no header and the
8890 // server falls back to the session org. Hosts without slugs (self-host,
8991 // cloudflare) never produce one either.
90−export const EXECUTOR_ORG_HEADER = "x-executor-organization";
92+export const EXECUTOR_ORG_HEADER = EXECUTOR_ORG_SELECTOR_HEADER;
9193
9294 export const getActiveOrgSlug = (): string | null => {
9395 const pathname = globalThis.window?.location?.pathname;
packages/react/src/plugins/oauth-sign-in.test.ts+46−0
@@ -0,0 +1,46 @@
1+import { afterEach, describe, expect, it } from "@effect/vitest";
2+import { OAUTH_CALLBACK_ORG_QUERY_PARAM } from "@executor-js/sdk/shared";
3+
4+import { oauthCallbackUrl } from "./oauth-sign-in";
5+
6+const originalWindow = globalThis.window;
7+
8+const setLocation = (href: string): void => {
9+ Object.defineProperty(globalThis, "window", {
10+ configurable: true,
11+ value: { location: new URL(href) },
12+ });
13+};
14+
15+afterEach(() => {
16+ if (originalWindow) {
17+ Object.defineProperty(globalThis, "window", {
18+ configurable: true,
19+ value: originalWindow,
20+ });
21+ return;
22+ }
23+ Reflect.deleteProperty(globalThis, "window");
24+});
25+
26+describe("oauthCallbackUrl", () => {
27+ it("returns a relative callback path outside the browser", () => {
28+ Reflect.deleteProperty(globalThis, "window");
29+ expect(oauthCallbackUrl()).toBe("/api/oauth/callback");
30+ });
31+
32+ it("carries the active org slug from the console URL", () => {
33+ setLocation("https://executor.sh/acme/integrations/posthog");
34+
35+ const url = new URL(oauthCallbackUrl());
36+
37+ expect(url.toString()).toBe("https://executor.sh/api/oauth/callback?executor_org=acme");
38+ expect(url.searchParams.get(OAUTH_CALLBACK_ORG_QUERY_PARAM)).toBe("acme");
39+ });
40+
41+ it("does not add an org selector on bare app routes", () => {
42+ setLocation("https://executor.sh/login");
43+
44+ expect(oauthCallbackUrl()).toBe("https://executor.sh/api/oauth/callback");
45+ });
46+});
packages/react/src/plugins/oauth-sign-in.tsx+7−1
@@ -14,6 +14,7 @@ import { · 13 unmodified lines
1414 type OAuthPopupResult,
1515 } from "../api/oauth-popup";
1616 import { connectionWriteKeys } from "../api/reactivity-keys";
17+import { getActiveOrgSlug } from "../api/server-connection";
1718
1819 type DesktopBridge = {
1920 readonly openExternal: (url: string) => Promise<void>;
@@ -30,6 +31,7 @@ const getDesktopBridge = (): DesktopBridge | null => { · 10 unmodified lines
3031 import { Button } from "../components/button";
3132 import {
3233 OAUTH_POPUP_MESSAGE_TYPE,
34+ OAUTH_CALLBACK_ORG_QUERY_PARAM,
3335 OAuthState,
3436 type AuthTemplateSlug,
3537 type ConnectionName,
@@ -96,7 +98,11 @@ export type StartOAuthAuthorizationInput<TPayload extends OAuthCompletionPayload · 60 unmodified lines
9698 };
9799
98100 export function oauthCallbackUrl(path = "/api/oauth/callback"): string {
99− return typeof window === "undefined" ? path : `${window.location.origin}${path}`;
101+ if (typeof window === "undefined") return path;
102+ const url = new URL(path, window.location.origin);
103+ const orgSlug = getActiveOrgSlug();
104+ if (orgSlug) url.searchParams.set(OAUTH_CALLBACK_ORG_QUERY_PARAM, orgSlug);
105+ return url.toString();
100106 }
101107
102108 export function useOAuthPopupFlow<