feat(apps): support the frontend SDK in sandboxed raw apps

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Gmsk9kAG7p9t2Qy6ADRJz
This commit is contained in:
Diego Imbert
2026-07-28 16:46:28 +02:00
co-authored by Claude Fable 5
parent fd2735d241
commit 025069c41e
7 changed files with 65 additions and 25 deletions
+17 -14
View File
@@ -830,6 +830,13 @@ fn raw_app_wrapper_html(secret: &str) -> String {
window.__wmStorageShim.local.__hydrate(d.storage.local);
window.__wmStorageShim.session.__hydrate(d.storage.session);
}
// Frontend SDK: a bundled `windmill-client` reads window.process.env at
// module load, so the env must exist before the bundle script below is
// injected. BASE_URL is supplied by the embedder rather than derived —
// location.origin is "null" on this opaque origin.
if (d.sdk && d.sdk.token) {
window.process = { env: { WM_TOKEN: d.sdk.token, BASE_URL: d.sdk.baseUrl, WM_WORKSPACE: d.sdk.workspace } };
}
if (d.initialHash && d.initialHash !== '#' && !window.location.hash) {
try { history.replaceState(null, '', d.initialHash); } catch (_) {}
}
@@ -1322,11 +1329,13 @@ async fn mint_raw_app_sdk_token(
/// apps whose policy declares `frontend_sdk_scopes` get the viewer-scoped SDK
/// token once the viewer confirms the permission prompt (`sdk_consent`).
///
/// `sdk_consent` is the viewer's answer to that prompt, not a security boundary:
/// an unsandboxed raw app's bundle runs same-origin with the viewer's session and
/// can call this endpoint itself. The boundary is the scope set — the token can
/// never exceed the policy-declared scopes, which are also capped by the viewer's
/// own permissions. Sandbox isolation is what actually contains an app's code.
/// For an unsandboxed app `sdk_consent` is the viewer's answer to that prompt,
/// not a security boundary: its bundle runs same-origin with the viewer's session
/// and can call this endpoint itself. The boundary is the scope set — the token
/// can never exceed the policy-declared scopes, which are also capped by the
/// viewer's own permissions. Sandbox isolation is what contains an app's code; a
/// sandboxed app holds nothing but this token, so there the prompt is the only
/// way it gains any reach at all.
///
/// The CALLER MUST verify that `opt_authed` may view `app_path` before calling:
/// this mints on their behalf unconditionally and does no visibility check of its
@@ -1342,15 +1351,9 @@ pub async fn build_embed_token_response(
sdk_consent: bool,
) -> Result<EmbedTokenResponse> {
// Only advertise scopes where a token could actually be minted, so the viewer
// never shows a permission prompt that can grant nothing: a sandboxed bundle
// lives on an opaque origin and can't use the token, and an anonymous visitor
// has no identity to mint against. The backend, not the viewer, decides this
// (the editor shows the author the same sandbox rule).
let sdk_scopes = if raw_app
&& !policy.sandbox
&& opt_authed.is_some()
&& !policy.frontend_sdk_scopes.is_empty()
{
// is never shown a permission prompt that can grant nothing: an anonymous
// visitor has no identity to mint against.
let sdk_scopes = if raw_app && opt_authed.is_some() && !policy.frontend_sdk_scopes.is_empty() {
Some(policy.frontend_sdk_scopes.clone())
} else {
None
+8 -1
View File
@@ -655,7 +655,14 @@ pub async fn run_server(
.layer(Extension(argon2.clone()))
.layer(cors.clone()),
)
.nest("/variables", variables::workspaced_service())
// CORS so a sandboxed raw app's opaque-origin bundle can
// read variables with its frontend SDK token. Bearer-only:
// the layer never allows credentials, so no cookie can ride
// a cross-origin call. Consistent with resources/users.
.nest(
"/variables",
variables::workspaced_service().layer(cors.clone()),
)
.nest("/volumes", volumes_oss::workspaced_service())
.nest("/workers", windmill_api_workers::workspaced_service())
.nest("/workspaces", workspaces::workspaced_service())
@@ -21,7 +21,10 @@
type OnBehalfOfChoice
} from '$lib/components/OnBehalfOfSelector.svelte'
import { canUserBypassRuleKind, protectionRulesState } from '$lib/workspaceProtectionRules.svelte'
import { FRONTEND_SDK_SCOPES } from '$lib/components/raw_apps/sdkScopes'
import {
FRONTEND_SDK_SCOPES,
MIN_SANDBOXED_SDK_VERSION
} from '$lib/components/raw_apps/sdkScopes'
const WM_DEPLOYERS_GROUP = 'wm_deployers'
@@ -370,9 +373,10 @@
{/if}
{#if policy.sandbox == true && policy.frontend_sdk_scopes?.length}
<div class="mt-2">
<Alert type="warning" title="Not available with sandbox isolation" size="xs">
A sandboxed app's bundle runs on an opaque origin and cannot use the SDK token. Turn off
sandbox isolation for these scopes to take effect.
<Alert type="info" title="Requires windmill-client {MIN_SANDBOXED_SDK_VERSION}" size="xs">
A sandboxed app calls the API cross-origin, which earlier versions of the SDK cannot do.
Make sure your app depends on <code>windmill-client@^{MIN_SANDBOXED_SDK_VERSION}</code> and
redeploy apps bundled against an older version fail with a CORS error.
</Alert>
</div>
{/if}
@@ -151,6 +151,7 @@
}
function respondCtx() {
const sdkToken = sdkTokenCtx?.value
iframe?.contentWindow?.postMessage(
{
type: 'windmill:ctx',
@@ -158,7 +159,14 @@
// `window.ctx.workspace` works for anonymous viewers too.
ctx: { ctx: user, workspace },
initialHash,
storage: { local: bundleStorage ?? {}, session: {} }
storage: { local: bundleStorage ?? {}, session: {} },
// The wrapper turns this into `window.process.env` before it injects
// the bundle, which is what a bundled `windmill-client` reads at
// module load. `baseUrl` must come from here: this component runs on
// the real origin, the bundle's is opaque.
...(sdkToken
? { sdk: { token: sdkToken, baseUrl: window.location.origin, workspace } }
: {})
},
'*'
)
@@ -3,6 +3,12 @@
// in the backend `apps.rs` — both lists must stay in sync), plus the viewer-side
// consent persistence for the permission banner.
/** A sandboxed app calls the API from an opaque origin, which needs the
* credential-free client shipped alongside this release: earlier versions force
* `credentials: 'include'`, which CORS rejects. Tracks the monorepo version
* because `windmill-client` is published from it in lockstep. */
export const MIN_SANDBOXED_SDK_VERSION = __pkg__.version
export const FRONTEND_SDK_SCOPES: { value: string; label: string; description: string }[] = [
{
value: 'jobs:run',
+6 -2
View File
@@ -22,12 +22,16 @@ const baseUrl = getEnv("BASE_INTERNAL_URL") ?? getEnv("BASE_URL") ?? "http://loc
const baseUrlApi = (baseUrl ?? '') + "/api";
EOF
# WITH_CREDENTIALS mirrors setClient: cookies only when there is no bearer token.
# A browser bundle that never calls setClient (a raw app, which gets its token
# from window.process.env) must not send credentials — the API answers
# `Access-Control-Allow-Origin: *`, so a credentialed cross-origin request fails.
if [[ "$OSTYPE" == "darwin"* ]]; then
sed -i '' 's/WITH_CREDENTIALS: false/WITH_CREDENTIALS: true/g' src/core/OpenAPI.ts
sed -i '' 's/WITH_CREDENTIALS: false/WITH_CREDENTIALS: !getEnv("WM_TOKEN")/g' src/core/OpenAPI.ts
sed -i '' 's/TOKEN: undefined/TOKEN: getEnv("WM_TOKEN")/g' src/core/OpenAPI.ts
sed -i '' "s/BASE: '\/api'/BASE: baseUrlApi/g" src/core/OpenAPI.ts
else
sed -i 's/WITH_CREDENTIALS: false/WITH_CREDENTIALS: true/g' src/core/OpenAPI.ts
sed -i 's/WITH_CREDENTIALS: false/WITH_CREDENTIALS: !getEnv("WM_TOKEN")/g' src/core/OpenAPI.ts
sed -i 's/TOKEN: undefined/TOKEN: getEnv("WM_TOKEN")/g' src/core/OpenAPI.ts
sed -i "s/BASE: '\/api'/BASE: baseUrlApi/g" src/core/OpenAPI.ts
fi
+11 -3
View File
@@ -68,7 +68,11 @@ export function setClient(token?: string, baseUrl?: string) {
if (token === undefined) {
token = getEnv("WM_TOKEN") ?? "no_token";
}
OpenAPI.WITH_CREDENTIALS = true;
// Cookies are the fallback for when there is no bearer token. Sending both is
// not just redundant, it breaks the browser case outright: the API answers
// `Access-Control-Allow-Origin: *` and so can never allow credentials, and a
// credentialed cross-origin request is rejected before the token is looked at.
OpenAPI.WITH_CREDENTIALS = token === "no_token";
OpenAPI.TOKEN = token;
OpenAPI.BASE = baseUrl + "/api";
}
@@ -79,8 +83,12 @@ function getPublicBaseUrl(): string {
export const getEnv = (key: string) => {
if (typeof window === "undefined") {
// node
return process?.env?.[key];
// `process` may be undeclared entirely (web worker, browser-like runtimes
// without a node shim), where `process?.env` still throws a ReferenceError.
if (typeof process !== "undefined") {
return process?.env?.[key];
}
return globalThis?.process?.env?.[key];
}
// browser
return window?.process?.env?.[key];