Compare commits

...
Author SHA1 Message Date
centdix 00392ba548 fix 2026-06-23 16:23:41 +02:00
hugocasaandClaude Opus 4.8 ba4b368706 fix: prevent variable push from corrupting is_secret variables (#9705)
* fix: prevent variable push from corrupting is_secret variables

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(cli): unit-test looksLikeWorkspaceCiphertext shape detection

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(cli): scope is_secret downgrade to single-file push, not sync push

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(cli): warn when variable push stores a secret value as already-encrypted

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(cli): route workspace-resolution and auth diagnostics to stderr

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(cli): rephrase comments to describe current behavior, not history

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 12:45:36 +02:00
Ruben Fiszelandrubenfiszel 723a65920f chore(main): release 1.737.0 (#9728)
* chore(main): release 1.737.0

* Apply automatic changes

---------

Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com>
2026-06-23 12:10:15 +02:00
c644311eca fix(ext-jwt): reject external JWT auth for non-existent workspaces (#9723)
* fix(ext-jwt): reject external JWT auth for non-existent workspaces

External JWTs are validated (not generated) on our side and never revoked
by us. The usage-tracking upsert into unique_ext_jwt_token ran
unconditionally, so a token carrying a workspace_id whose workspace no
longer exists kept refreshing its row on every presentation — surfacing
as a "new token" in the superadmin external-JWT view.

Gate jwt_ext_auth on the requested workspace existing (EE companion). When
it does not, auth fails (token is unusable) and no usage row is written.
The check is existence-only and intentionally ignores the soft-delete
flag, so deleted-then-restored workspaces keep working.

Bumps ee-repo-ref.txt to the EE companion commit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* perf(ext-jwt): cache workspace-existence lookups in jwt_ext_auth

Bumps ee-repo-ref.txt to the EE companion commit that caches the
workspace-existence check added in the previous commit, so a token aimed
at a missing workspace no longer hits the DB on every request (auth
failures aren't cached upstream).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore: update ee-repo-ref to ac1f6f666f36141cb6ba6f8eaa614821a90464ad

This commit updates the EE repository reference after PR #626 was merged in windmill-ee-private.

Previous ee-repo-ref: e23fa03ec16909c127e8ecf0855595911c29512d

New ee-repo-ref: ac1f6f666f36141cb6ba6f8eaa614821a90464ad

Automated by sync-ee-ref workflow.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
2026-06-23 11:25:23 +02:00
Ruben FiszelandClaude Opus 4.8 31d9215e5a fix: bound orphan-cleanup drain rate with capped multi-batch loop (#9730)
Follow-up to #9727. The orphan cleanups (cleanup_job_perms_orphaned and
cleanup_job_result_stream_orphaned_jobs) deleted at most one 100k batch per
monitor iteration. Each statement stays short and lock-light, but a single
batch per ~30s cycle caps the drain rate at ~100k/30s, so a large one-time
backlog (tens of millions of rows) takes ~hours to clear.

Loop the batched delete up to ORPHAN_CLEANUP_MAX_BATCHES (10) times per cycle,
stopping early once a batch deletes fewer than ORPHAN_CLEANUP_BATCH_SIZE rows.
Each DELETE remains bounded (≤100k, short locks, no long single statement),
while per-cycle throughput rises to ~1M rows so backlogs drain ~10x faster.
The per-cycle cap keeps monitor_db responsive.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 11:24:36 +02:00
Ruben FiszelandClaude Opus 4.8 75bafabeee perf(monitor): hash active-root exclusion in retention delete (WIN-2088) (#9732)
* perf(monitor): hash active-root exclusion in retention delete

The expired-job retention delete (delete_expired_jobs_batch) excluded jobs
belonging to still-active root flows with
`COALESCE(j.root_job, j.flow_innermost_root_job, jc.id) != ALL($3)`. That
ScalarArrayOp is evaluated per candidate row as a linear scan of $3, so cost
grows with the number of active root jobs.

Express the exclusion as `NOT IN (SELECT u FROM unnest($3) u WHERE u IS NOT
NULL)` instead. The subquery form lets Postgres build a one-time hashed SubPlan
and apply it as a filter on the ordered index scan, giving O(1) membership per
candidate while preserving the `ORDER BY completed_at ASC LIMIT` early
termination. The `u IS NOT NULL` guard sidesteps NOT IN's null-trap semantics
($3 holds non-null PK ids).

Measured on a 2M-row synthetic v2_job_completed (batch LIMIT 20000, 5-run min):

  active roots | != ALL (before) | NOT IN hashed (after)
  -------------|-----------------|----------------------
  100          | 108 ms          | 104 ms
  1000         | 168 ms          | 105 ms
  10000        | 719 ms          | 131 ms

Both forms return identical row sets (verified via EXCEPT, 0 diff). Neutral at
small active-root counts, ~5.5x faster when many flows are active.

Relates to WIN-2088

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* perf(monitor): apply hashed active-root exclusion to log_cleanup mirror

windmill-api-settings/log_cleanup.rs::delete_expired_jobs_batch carries a
byte-identical copy of the retention delete and shared its prepared-query
cache. Updating only monitor.rs removed that shared cache entry and broke the
SQLX_OFFLINE build of the mirror. Apply the same NOT IN (hashed SubPlan)
rewrite so both copies converge on one cached query and the mirror gets the
same speedup.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 11:23:50 +02:00
Ruben FiszelandClaude Opus 4.8 fa3596885b fix: allow SQL args in managed // materialize scripts (#9733)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 11:13:04 +02:00
Ruben FiszelandClaude Opus 4.8 6d94865109 fix: optimize cleanup_job_perms_orphaned and job_result_stream cleanup queries (#9727)
The job_perms/job_result_stream_v2 orphan cleanups in monitor_db used
`NOT IN` anti-joins, `RETURNING job_id` + `fetch_all` (loading every deleted
UUID into memory) and no batch limit. On high-throughput instances these
tables can accumulate tens of millions of orphaned rows, so a single execution
ran for ~298s; because monitor_db awaits each iteration, the cleanup ran
effectively continuously, saturating DB I/O and starving audit partition
creation.

Rewrite both deletes as bounded `NOT EXISTS` anti-joins selecting `ctid` with
a LIMIT 100000, executed via `.execute()` (using rows_affected instead of
fetch_all). Each run is now fast and bounded, while the 30s monitor cadence is
preserved so the tables keep draining promptly.

Fixes WIN-2088

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 10:14:40 +02:00
Ruben FiszelandClaude Opus 4.8 8dea38383f fix: prevent silent audit-partition outage via monitor watchdog + alert (#9729)
The monitor loop runs ~25 periodic tasks under a single join!, so any one
stuck on a non-DB await (statement_timeout only bounds DB statements) freezes
the whole loop indefinitely — silently halting audit-partition creation. Once
the missing partition's date is reached, audit inserts fail; because login
writes its audit row in the same transaction, that poisons the login tx and
locks every user out.

- Wrap monitor_db in a 600s timeout (> statement_timeout) so a stuck task can
  no longer freeze the loop; report a critical error and continue on timeout.
- After creating partitions, verify the lookahead window is actually covered
  and raise a critical alert naming any missing partitions, turning a silent
  latent outage into an early page.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 10:13:29 +02:00
2879cbb65a feat(apps): opt-in sandbox isolation for published & raw apps (alpha) (#9420)
* feat(apps): sandbox published & raw apps with a scoped embed token

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore: point ee-repo-ref at embed-token EE commit

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(apps): allow top-navigation from the sandboxed app iframe

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(apps): share app localStorage across apps via the embedder

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(apps): publisher disable-sandbox option with per-version viewer consent

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(sqlx): cache for disable-sandbox queries

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore: bump ee-repo-ref to disable-sandbox EE commit

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(apps): always sandbox the served raw-app wrapper + viewer fixes

The raw-app wrapper served by get_raw_app_data now always carries
`CSP: sandbox`. The publisher "disable sandbox isolation" opt-out is applied
entirely on the viewer side, which (after per-version consent) builds its own
same-origin blob wrapper — so the backend-served document stays isolated
regardless of how it is reached, never via a relaxed real-origin URL.

Also:
- CORS on the global /apps_u mount so the opaque viewer can load custom-path
  public apps cross-origin.
- Reject runnable-bridge messages unconditionally until the iframe is bound.
- Relay the viewer's in-app hash up to the embedder address bar so deep links
  stay shareable (hash only; embedder keeps its own pathname).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(apps): render public raw apps single-iframe (drop embed token)

Public raw apps now render directly on the real origin with a single
opaque bundle iframe and the page credential, instead of the opaque
viewer + scoped-token indirection. The author bundle stays isolated in
its own opaque iframe (CSP-sandboxed); low-code apps, whose code runs in
the viewer frame, keep the opaque viewer + scoped token.

embed_token now reports raw_app and skips minting a token for raw apps;
the access check still gates visibility.

Also set disable_sandbox: None in the remaining Policy constructors so
the full feature build (all_sqlx_features, enterprise, license) compiles.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore: bump ee-repo-ref to single-iframe raw-app EE commit

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(apps): grandfather existing apps as legacy-unsandboxed + authed-only consent

Existing apps are stamped by migration as `legacy_unsandboxed` so they keep
running same-origin on upgrade — no breakage and no consent prompt. New apps are
sandboxed by default; re-deploying an app clears the flag.

The publisher `disable_sandbox` consent prompt is now shown only to authenticated
viewers — an anonymous viewer has no session to expose, so the prompt was
meaningless friction.

embed_token reports `legacy_unsandboxed` and `authed`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore: bump ee-repo-ref to legacy-unsandboxed EE commit

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(apps): deploy-time migration prompt for legacy-unsandboxed apps

On the first re-deploy of a grandfathered (legacy-unsandboxed) app, the
publisher must explicitly choose: enable sandbox isolation (the flag is
cleared → the app becomes sandboxed) or keep running without isolation
(→ disable_sandbox, with per-version viewer consent). updatePolicy() no
longer carries the legacy flag through a deploy, so the choice is what
sticks.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(apps): disable the sandbox-isolation toggle until the app is deployed

The Deploy-drawer "Disable sandbox isolation" toggle called setPublishState()
— which updates the app by path — even before the app was first deployed, when
the path is empty, throwing an error. Guard it with disabled={!savedApp},
matching the adjacent visibility toggle.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(apps): sandbox the in-workspace low-code app viewer in an opaque iframe

Extend the opaque-origin iframe isolation to the logged-in /apps/get viewer.
/apps/get becomes an embedder that keeps the workspace chrome + Edit button and
renders the app inside a cookieless, chrome-less /app_embed viewer route, handed
a scoped embed token minted from the member's session. The app frame runs in an
opaque origin (no allow-same-origin), so it cannot reach the member's session
cookie or window.parent.

- apps.rs: get_app_embed_token_for_path (authed, by-path, scope + RLS gated);
  mint_app_embed_token grants a path-scoped apps:read:{path} so the viewer can
  load its own app definition and no other
- lib.rs: CORS on /apps (bearer-token only, no cookies) for the opaque viewer's
  by-path reads
- new /app_embed/[workspace]/[...path] viewer route (private analog of /public)
- PublicAppFrame: viewerUrl prop to point the opaque iframe at the viewer route

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(apps): unify in-workspace app viewers on the shared sandboxed path

Route every in-workspace app display (low-code and raw) through the same
PublicAppFrame -> PublicApp machinery as the public viewer, so the sandbox /
legacy-unsandboxed / disable-sandbox-consent behavior is identical on every page.

- new InWorkspaceAppViewer renders both app types via PublicAppFrame; /apps/get
  and /apps_raw/get become thin wrappers over it
- /apps_raw/get previously rendered RawAppPreview directly (always isolated, with
  no legacy-grandfathering or consent handling); now consistent with the rest
- retire the legacy same-origin raw viewer /apps/get_raw/[version] and re-point the
  apps-list row to /apps_raw/get; remove the dead /apps_raw/[ws]/[version] route
- load the raw bundle secret in the shared viewer (getAppByPath doesn't return it)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(apps): address PR review feedback (scope + policy hardening, nits)

- require handler-level apps:read on list_apps / list_search_apps so a scoped
  embed token cannot read app definitions through the list endpoints. The route
  layer treats apps:run as satisfying read; the handler check (which does not)
  closes the gap.
- treat legacy_unsandboxed as backend-owned: strip any client-provided value in
  create/update so it can only be set by the grandfather migration, not the API.
- document mint_app_embed_token's caller-verifies-access contract.
- use Button's declared onClick prop for the consent action (was onclick, which
  fell into the rest-spread and bypassed the component's click handling).
- test: lock that the embed scopes cannot satisfy domain-level apps:read.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(apps): document embed-token endpoints in openapi + fix doc nit

Second-round review nits:
- add the three app embed-token endpoints (apps/embed_token/p/{path},
  apps_u/embed_token/{secret}, and the EE apps_u/embed_token_by_custom_path) plus
  the EmbedTokenResponse schema to openapi.yaml; note .html on get_data
- mint_app_embed_token doc: "Both" -> "All" (it lists three call sites)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(apps): bound embed-token scopes to the caller's own

The embed-token mint now enforces ensure_scopes_within_caller, so the
minted scope set is always within the calling credential's own scopes
(a no-op for regular unscoped sessions). Adds a unit test locking the
boundary and documents the contract on mint_app_embed_token.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(apps): raw-app ctx in external embeds + page credential in direct render

- RawAppPreview: engage the storage relay only in opaque frames (probe Web
  Storage instead of just window.parent), so a public raw app embedded in an
  external iframe hydrates ctx/storage directly; add a relay-timeout fallback
  so an unresponsive parent can never stall the ctx handshake.
- PublicAppFrame: in direct render, expose the page's own bearer credential
  through the AuthToken context (JWT public URLs), matching the previous
  route behavior; opaque-viewer mode keeps the embed token.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(apps): sandbox isolation UI polish + COI embed support for raw apps

- Deploy drawer: move the sandbox toggle out of "Public URL" into its own
  "Sandbox isolation" section (the setting applies to every viewing surface,
  not just the public URL), with positive phrasing, visible helper text, and
  state-aware alerts (warning when disabled, info for pre-isolation apps).
  Toggling it now toasts its own message instead of the login-mode one.
- Extract the deploy-time migration prompt into a shared
  LegacySandboxMigrationModal built on the common Modal component, and wire
  it into the raw app editor header too (it previously had no prompt, so
  re-deploying a pre-isolation raw app silently changed behavior).
  updateRawAppPolicy now also drops the backend-owned legacy flag, matching
  the low-code updatePolicy.
- Viewer consent prompt: use the common ConfirmationModal and show the app
  path (new appPath prop) instead of the route pathname, falling back to
  "this app" when the path isn't known yet.
- COI embeds: propagate the wm_coep opt-in to the raw-app wrapper document
  and have the backend assert COEP require-corp on it when the flag is
  present — required for the bundle iframe to load when the public app page
  is embedded inside a cross-origin-isolated page. Previously this only
  worked in dev because the Vite proxy injects the header; the production
  response lacked it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(apps): app navigation parity across sandboxed and direct viewers

- Navbar component: same-app items relay query + hash to the embedder page
  (which mirrors them onto the root URL, keeping its own pathname and
  transport params), app items navigate the top page through a validated
  wm_embed_navigate relay instead of the cookieless viewer iframe, and
  external items keep opening a new tab. Selected-item detection now
  recognizes the /app_embed viewer route and ignores transport params.
- Frontend-script `goto` and button `onSuccess: gotoUrl`: same-window
  navigation goes through a shared appNavigateSameWindow helper that relays
  to the embedder inside the opaque viewer (same-origin paths SPA-navigate,
  http(s) URLs do a full load, other schemes rejected) and keeps plain
  window.location everywhere else.
- /apps/get and /apps_raw/get: key the viewer by workspace/path so in-route
  navigation fully remounts it — previously the URL changed but the app (and
  in sandbox mode its path-scoped token) did not follow.
- wm_embed/wm_embedder_origin added to the reserved query params so they no
  longer leak into the app's ctx.query.
- Raw apps: drop the sandbox attribute entirely for the unsandboxed
  (grandfathered/consented) blob path, matching the pre-isolation viewer
  exactly — the attribute added no isolation there and sandboxed popups
  (e.g. OAuth flows).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(apps): preserve grandfathered policy across updates + in-workspace viewer parity

Round of compatibility hardening so pre-existing apps behave exactly as
before on every surface:

- `legacy_unsandboxed` is now preserved across app updates unless the payload
  explicitly clears it (`false`, sent by the editor's migration prompt and the
  sandbox toggle). Unrelated update paths — CLI / git-sync redeploys,
  publish-mode toggles, cross-workspace promotion — no longer silently drop
  the grandfathering. Clients still can never SET the flag.
- The embed-token endpoints (secret, path, EE custom-path) read only the
  sandbox-decision policy fields, leniently, and no longer mint a token for
  raw / legacy / disable_sandbox renders: the token is only consumed by the
  sandboxed low-code render, and minting for the others wrote a useless token
  row per view and could fail the render for scope-restricted callers.
- In-workspace viewer parity with the pre-sandbox `/apps/get`: new
  `inWorkspace` mode on PublicApp (no "Powered by Windmill" badge / user
  overlay, no HTML-result approval gate, column flex wrapper, `hideRefreshBar`
  honored again), and the page's query/hash are forwarded into the opaque
  viewer so `ctx.query` / `ctx.hash` reach the app.
- Raw apps: `window.ctx` is always `{ctx, workspace}` again (anonymous viewers
  of pre-existing bundles rely on `ctx.workspace`), and the runnable bridge's
  job-id scoping now applies only to sandboxed renders (`gateJobIds`) — an
  unsandboxed bundle holds the same credential as the bridge, so gating there
  only broke pre-existing apps polling persisted or runnable-returned job ids.
- Document `disable_sandbox` / `legacy_unsandboxed` in the openapi Policy
  schema; add a unit test for the lenient policy read.
- bump ee-repo-ref to the matching EE commit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(apps): keep share-link viewer credentials out of the isolated app context

The JWT path segment of authenticated share URLs is an embedder-side
credential, consumed only to mint the scoped embed token. Two transport
channels still copied it into the isolated frame where app-authored code
runs:

- the opaque viewer iframe src defaulted to window.location.href — the
  public and custom-path routes now pass a sanitized viewerUrl (JWT segment
  stripped, query/hash preserved, captured once so the hash relay does not
  reload the iframe);
- document.referrer on the same-origin iframe navigation carried the full
  embedder URL — both app iframes now set referrerpolicy="no-referrer"
  (sandboxed renders only for the raw bundle iframe, keeping exact legacy
  parity; nothing reads the referrer).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(frontend): drop unused import inherited from main merge

`slide` import in AssistantMessage.svelte (from #9539) turns `npm run check`
red on this branch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(apps): redirect the removed raw-app viewer path to the unified viewer

The old same-origin raw-app viewer route (/apps/get_raw/{version}/{path}) was
removed in favor of the sandboxed unified viewer. Re-add a thin client route at
the old path that redirects stale bookmarks to /apps_raw/get/{path}, preserving
query + hash (the pinned version is dropped — the unified viewer shows latest).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(apps): narrow embed-token scopes and base consent on browser session

- Embed token: resource access is metadata-only (list/type/exists) via a
  `resources:run` marker — resource values (get/get_value/get_value_interpolated/
  list_search) are no longer reachable. Job reads are by-id only: an `app_embed`
  sentinel blocks the workspace-wide job enumeration/export routes (jobs/list,
  list_filtered_uuids, queue/list, completed/list, queue/export) while by-id
  result polling keeps working.
- disable_sandbox consent now gates on whether the browser holds any Windmill
  session (cookie-only whoami) rather than workspace-scoped auth, so a viewer
  logged into a different workspace is still prompted before a same-origin render.
- db-explorer: resolve the MySQL database name server-side (the metadata query
  already falls back to DATABASE()) instead of reading the resource value
  client-side; getTablesByResource derives the default db from the schema.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(apps): trim embed-scope and consent comments

Reduce duplication — state the resource/job route exclusions and the
workspace-session-vs-cookie rationale once at their source and reference them
elsewhere; drop contrast/justification phrasing. No behavior change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(apps): make app sandbox isolation opt-in (alpha)

Replace the disable_sandbox + legacy_unsandboxed policy pair and the
per-version viewer consent with a single positive `sandbox` opt-in flag.
Apps are unsandboxed by default (same-origin, full session — the
pre-isolation behavior), so existing apps are unchanged and no migration
is needed. Publishers opt an app into isolation from the deploy drawer,
flagged alpha.

- Policy.sandbox: Option<bool>; EmbedTokenResponse -> {token, expiration,
  raw_app, sandbox}; mint an embed token only for sandboxed low-code apps.
- Drop the legacy-unsandboxed migration and the deploy-time migration
  prompt; remove the consent modal and the browser-session probe.
- Deploy drawer: a single "Sandbox isolation" toggle (alpha), off by
  default, shared by the low-code and raw editors.
- Bump ee-repo-ref to the companion EE commit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(apps): confine embed token to its intended user/folder/job routes

The embed token's broad read scopes spanned whole domains while the
matching routers are CORS-enabled for the opaque app iframe:

- users:read / folders:read were domain-wide, so the token could reach
  users/list, users/list_usage, users/username_to_email/*, folders/list,
  etc. Restrict to an app_embed-sentinel allowlist: only users/whoami and
  folders/listnames; deny the rest of those domains.
- jobs:read allowed jobs/completed/export, missed by the job denylist.
  Add it alongside jobs/queue/export.

Extend the embed-scope allow/deny test matrix to cover all of these.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(apps): align sandbox comments with the opt-in model

The consent prompt, deploy-time migration, and legacy-unsandboxed
grandfathering were removed when sandbox isolation became an opt-in
policy flag; update the comments that still described them so they
match the two-state (default-unsandboxed / opt-in-sandboxed) reality.
Comments only, no behavior change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(apps): confine embed-token job reads to runs the app launched

App component jobs are stamped `created_by = the viewer`, so an embed token
reads its own runs via the launched-by-viewer fast path. The token then also
inherited the viewer's broader job access (share links, folder ACLs, admin
RLS), letting user-authored app JS reuse it to read unrelated jobs by id. Stop
embed tokens at the fast path: only jobs the viewer launched, never those
merely visible to them. Return NotFound so the untrusted app can't probe
existence.

Regression test: an embed token reads its own launched job but is denied the
foreign job (result/logs/getupdate) an admin viewer's normal token can read.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(apps): allowlist embed-token apps/jobs routes + scope run to the app

The embed token's apps:run/jobs:read reached more than a running app needs.
Replace the job denylist with strict per-domain allowlists on the app_embed
sentinel:

- Apps: only the app's own definition (apps/get/p/<path>) and the public
  app-serving endpoints (apps_u/*). Denies workspace app inventory
  (exists, custom_path_exists, list, list_paths*).
- Jobs: only the by-id poll routes the frontend JobLoader uses. Denies job
  counts and the job_signature/resume_urls capability-minting routes (the
  by-id reads remain confined to the app's own runs).

Drop unqualified apps:run from APP_EMBED_SCOPES; mint apps:run:<path> instead
and authorize apps:run:<requested path> first in execute_component, so the
token can only run its own app's components, not another app's.

Extend the embed-scope route matrix and add a path-scoped run unit test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(apps): clarify the sandbox toggle vs the on-behalf-of model

The deploy-drawer sandbox copy leaned on "session" in a way that collided
with the on-behalf-of permissioning right above it. Reword it to say the
toggle governs what the app's browser-side code can reach in the viewer's
browser — distinct from who its runnables execute as — and rename the label
to "Isolate the app from the viewer's browser session".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(apps): path-scope embed-token S3 download to its own app

The apps_u/* allowlist also admitted apps_u/download_s3_file/<path>, whose
handler authorized any authenticated caller — so an embed token minted for app
A could download app B's S3 files via B's on-behalf policy. Add the same
path-scoped guard execute_component uses: download_s3_file_from_app now checks
apps:read:<path> first, confining the token to its own app. Other path-taking
apps_u routes are already covered (writes lack apps:write; embed_token/p
path-checks; public_resource is type-constrained).

Extend the path-scoping unit test to cover apps:read (download) alongside
apps:run (execute).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(apps): path-scope public-app-by-secret read to the embed token's app

The apps_u/* allowlist admitted apps_u/public_app/<secret>, whose handler only
checked the viewer's read access — so an embed token minted for app A could read
app B's definition by secret (confused deputy via the viewer's identity).
get_public_app_by_secret now binds a scoped caller to the resolved app with
check_scopes(apps:read:<path>), confining it to its own app; unscoped sessions
and anonymous access are unchanged.

get_raw_app_data needs no binding (pure secret capability, no caller identity).
Document the full set of app-resolving handlers the path-scoped read covers.

Bump ee-repo-ref for the companion custom-path fix.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(apps): preserve pre-sandbox behavior for db-explorer, edit link, jwt

Three behavior-parity fixes for non-sandboxed (existing) apps that the
sandbox-isolation refactor changed incidentally:

- DB-explorer MySQL table picker: when the connection can see multiple
  non-system schemas, label the default db's tables unprefixed again. The
  resource-value read was removed globally, so identify the default db from
  the introspection script's `DATABASE() AS default_db_name` (carried on
  SQLSchema.defaultDb) instead of guessing "the single schema key". Equivalent
  to the prior resource.database match; editor-only (table picker).
- In-workspace Edit button: restore `?nodraft=true` on both /apps/get and
  /apps_raw/get, so opening the editor from the viewer loads the deployed
  version, not a draft.
- Custom-path (/a) viewer: restore the "could not authenticate user with jwt
  token" toast when a path JWT fails to resolve a user, instead of silently
  falling through.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(apps): confine embed-token S3 downloads to the app's own keys/outputs

download_s3_file_from_app authorized any authenticated caller for any S3 key
(opt_authed.is_some() bypass). A sandboxed app's embed token carries the
viewer's identity, so app-authored JS could fetch arbitrary S3 keys readable by
the on-behalf identity, beyond the app's own declared keys or outputs.

Route app embed tokens through the same allowlist as anonymous viewers — the
app's declared allowed_s3_keys, or files produced by this app's own component
runs — instead of the authed bypass. The produced-files check is parameterized
by created_by (the embed viewer for a token, else anonymous) so a sandboxed
app's own S3 outputs still render while arbitrary keys are denied.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(apps): let embed tokens cancel their own jobs; gate cancel to launcher

A sandboxed low-code app supersedes an in-flight component run on re-run by
canceling it, but the embed token only had jobs:read, so cancellation silently
failed and prior jobs ran to completion.

- Permit the by-id jobs_u/queue/cancel POST for app_embed tokens at the route
  layer (the only write reachable through the existing by-id allowlist).
- Gate cancel_job_api: an app_embed token may cancel ONLY jobs it launched
  (created_by == viewer). cancel_job_api had no other per-job ownership check,
  so this also confines the token instead of letting it cancel any job by id.
- /app_embed now sets workspaceStore so cancellation targets the right
  workspace instead of an empty/stale one in the cookieless iframe.

Add a shared has_app_embed_sentinel helper; cover cancel in the route matrix
and the jobs_read_auth integration test (own job cancelable, foreign denied).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(apps): drop get_root_job_id from the embed-token job allowlist

Audit of the embed token's reachable job routes: get_root_job (jobs_u/
get_root_job_id) has no access check in its handler at all — it returns any
job's root-job id by id — and the app runtime never calls it. Remove it from
the by-id allowlist so the embed token can't probe a foreign job's flow lineage;
add a denied-route assertion.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(apps): scope sandboxed-app localStorage per app

Sandboxed apps shared one localStorage store (one key on the real origin), so an
app could read or clobber another app's keys — and, with job ids stashed there,
reuse its embed token to read another app's job. Scope the backing store per app.

The embed-token endpoints now return the resolved app_path (EmbedTokenResponse;
not a new disclosure — the viewer already receives the path when it loads the
app). PublicAppFrame (low-code) and RawAppPreview (raw) key their backing store
by it: wm_apps_localstorage:<app_path>. Same app shares one store across its
public and in-workspace surfaces; different apps are isolated. Unsandboxed apps
are unaffected (real same-origin localStorage, as before).

Bump ee-repo-ref for the companion custom-path change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(apps): scope embed access checks to embed tokens + key app storage by workspace

- Apply the path-scoped read/run checks on the public-by-secret read and the
  component run path only when the caller is an app embed token, so other
  caller types keep their prior access.
- Key the sandboxed app's backing client storage by workspace + path instead
  of path alone, and return the resolved workspace from the embed-token
  endpoints so the custom-path viewer can derive it.
- Show a clear message instead of an indefinite loader when the viewer route
  is opened outside its embedder.

Bumps ee-repo-ref to 5b8476b.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(apps): mint embed tokens only from the trusted embedder caller

An app embed token must not reach the embed-token mint endpoints; refresh
minting stays with the embedder session/JWT. Enforced at the scope route
layer and at the mint chokepoint, with a route-matrix regression test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(apps): support S3 upload and frontend-script S3 download in sandboxed apps

Sandboxed apps run with a scoped embed token (no cookie). Let the app's
S3 file-input upload and the frontend-script download({s3}) helper work in
that context: upload is reachable with apps:run and re-checked per-app at the
handler; the script download routes through the app-scoped apps_u endpoint
with the embed token instead of the cookie-authed job_helpers path. Default
(unsandboxed) apps are unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore: update ee-repo-ref to b0cb761bf9852974e571b2978032d310cc998517

This commit updates the EE repository reference after PR #600 was merged in windmill-ee-private.

Previous ee-repo-ref: e673c714a4618fdb72353a475f49c748e6016642

New ee-repo-ref: b0cb761bf9852974e571b2978032d310cc998517

Automated by sync-ee-ref workflow.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
2026-06-23 10:05:00 +02:00
Ruben Fiszelandrubenfiszel e82a6a6830 chore(main): release 1.736.0 (#9720)
* chore(main): release 1.736.0

* Apply automatic changes

---------

Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com>
2026-06-23 09:44:35 +02:00
Ruben FiszelandClaude Opus 4.8 e16061df06 fix(health): detect read-only replica via pg_is_in_recovery() (#9722)
The /api/health/status database check used `SELECT 1`, which succeeds
even on a read-only standby. After a PostgreSQL failover where the
primary becomes a secondary, the health check kept reporting healthy
while all writes failed with "cannot execute INSERT in a read-only
transaction", so Kubernetes liveness probes never restarted the pod.

Use `SELECT NOT pg_is_in_recovery()` instead: it returns true on a
primary and false on a standby, so a read-only replica is now reported
unhealthy. Result handling checks the returned bool (Ok(Some(true)))
rather than just query success.

Fixes WIN-2085

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 09:37:29 +02:00
Ruben Fiszel 3bf5b72afa fix(drafts): stop mis-filing workspace-blind legacy drafts on migration (#9725) 2026-06-23 09:27:02 +02:00
centdixandClaude Opus 4.8 6f4017d694 feat(ai-chat): workspace AI chat skills (SKILL.md upload + read_skill tool) (#9648)
* feat(ai-chat): workspace ai_skill table + CRUD API

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(ai-chat): AI Skills workspace settings tab with SKILL.md upload

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(ai-chat): advertise skills in global system prompt + read_skill tool

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(ai-chat): move custom skills into AI settings (paste or folder)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(ai-chat): cap folder import (depth<=3, max 50 skills, confirm dialog)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* style(ai-chat): give import folder its own labeled subsection

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(ai-chat): resolve svelte-check never-narrowing in skills preview

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: address ai skills review issues

* fix: validate ai skills and reload workspace list

* fix(ai-chat): spec-align skill validation and cap skills per workspace

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(ai-chat): reject duplicate skill uploads, audit skill names

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(ai-chat): sync deref openapi specs with skill validation rules

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 00:16:13 +02:00
Ruben FiszelandClaude Opus 4.8 d5cb944cf9 fix(frontend): ensure type:object in test_run_flow tool schema for Anthropic (#9721)
Flows with no defined inputs can produce a sparse schema (e.g. { order: [] })
that lacks the "type": "object" field. buildSchemaForTool spread this schema
into the tool parameters as-is, so the Anthropic API rejected the tool
definition with `400 invalid_request_error:
tools.N.custom.input_schema.type: Field required`. The existing fallback in
anthropic.ts only triggers when parameters is falsy, but the sparse schema is
truthy.

Default type:object before spreading the schema in buildSchemaForTool, and
backfill type/properties/required in FlowAIChat's getFlowInputsSchema as a
defense-in-depth measure.

Fixes WIN-2087

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 00:08:13 +02:00
hugocasaandClaude Opus 4.8 e19594df2a fix: re-enforce scoped API token boundaries across handlers (#9712)
* fix: re-enforce per-path token scope on store rename, delete and interpolation

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: enforce token scope on workspace export and resume-url minting

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: enforce per-item and runnable scope on trigger create paths

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: enforce app write scope before persistence and on rename

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: enforce scope containment on mcp oauth approval

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: scope mcp endpoint-proxy jwt to the proxied route

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: treat resource-linked variables and resources as covered by resource scope

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: only require variables:read for plaintext-secret workspace export

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: handle singlestepflow resume, reject empty mcp grant, scope var-skipped tarball

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 23:55:19 +02:00
Ruben FiszelandClaude Opus 4.8 6e96f90065 fix(frontend): destroy old WebsocketProvider on workspace switch in MultiplayerMenu (#9719)
Switching workspaces created a new WebsocketProvider without destroying
the old one. The leaked provider kept reconnecting, causing alternating
websocket traffic between old and new workspace rooms and flickering in
the Live Activity sidebar.

Add a disconnectWorkspace() cleanup that destroys the provider and resets
connected/awareness state, call it at the start of connectWorkspace()
before creating a new provider (matching ScriptEditor.svelte), and run it
from an onDestroy hook so the provider is torn down on unmount.

Fixes WIN-2086

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 23:20:08 +02:00
116 changed files with 6850 additions and 1064 deletions
+37
View File
@@ -1,5 +1,42 @@
# Changelog
## [1.737.0](https://github.com/windmill-labs/windmill/compare/v1.736.0...v1.737.0) (2026-06-23)
### Features
* **apps:** opt-in sandbox isolation for published & raw apps (alpha) ([#9420](https://github.com/windmill-labs/windmill/issues/9420)) ([2879cbb](https://github.com/windmill-labs/windmill/commit/2879cbb65a4122c86b4a472206d74d9009b07904))
### Bug Fixes
* allow SQL args in managed // materialize scripts ([#9733](https://github.com/windmill-labs/windmill/issues/9733)) ([fa35968](https://github.com/windmill-labs/windmill/commit/fa3596885bf2d7ee8859f295e820ee362c756911))
* bound orphan-cleanup drain rate with capped multi-batch loop ([#9730](https://github.com/windmill-labs/windmill/issues/9730)) ([31d9215](https://github.com/windmill-labs/windmill/commit/31d9215e5a61f19662cc87be8147007e1d47ebb6))
* **ext-jwt:** reject external JWT auth for non-existent workspaces ([#9723](https://github.com/windmill-labs/windmill/issues/9723)) ([c644311](https://github.com/windmill-labs/windmill/commit/c644311eca4bcaf4b68058cf1d5d79d4078aee1a))
* optimize cleanup_job_perms_orphaned and job_result_stream cleanup queries ([#9727](https://github.com/windmill-labs/windmill/issues/9727)) ([6d94865](https://github.com/windmill-labs/windmill/commit/6d9486510933af9109f52011d93b13847dbdbb39))
* prevent silent audit-partition outage via monitor watchdog + alert ([#9729](https://github.com/windmill-labs/windmill/issues/9729)) ([8dea383](https://github.com/windmill-labs/windmill/commit/8dea38383f884f59b2956c39f1424005a21265bd))
### Performance Improvements
* **monitor:** hash active-root exclusion in retention delete (WIN-2088) ([#9732](https://github.com/windmill-labs/windmill/issues/9732)) ([75bafab](https://github.com/windmill-labs/windmill/commit/75bafabeeec76cf6da33eef41f588e37071df011))
## [1.736.0](https://github.com/windmill-labs/windmill/compare/v1.735.0...v1.736.0) (2026-06-23)
### Features
* **ai-chat:** workspace AI chat skills (SKILL.md upload + read_skill tool) ([#9648](https://github.com/windmill-labs/windmill/issues/9648)) ([6f4017d](https://github.com/windmill-labs/windmill/commit/6f4017d694494a158ebd0579c93336c378cba0fd))
### Bug Fixes
* **drafts:** stop mis-filing workspace-blind legacy drafts on migration ([#9725](https://github.com/windmill-labs/windmill/issues/9725)) ([3bf5b72](https://github.com/windmill-labs/windmill/commit/3bf5b72afab3241ea41a261a40c2434764bdaf72))
* **frontend:** destroy old WebsocketProvider on workspace switch in MultiplayerMenu ([#9719](https://github.com/windmill-labs/windmill/issues/9719)) ([6e96f90](https://github.com/windmill-labs/windmill/commit/6e96f90065dfe2f6ccc5eb4f85f4facd3515c70c))
* **frontend:** ensure type:object in test_run_flow tool schema for Anthropic ([#9721](https://github.com/windmill-labs/windmill/issues/9721)) ([d5cb944](https://github.com/windmill-labs/windmill/commit/d5cb944cf92f074b2ee42c876595eacdfa2f4d76))
* **health:** detect read-only replica via pg_is_in_recovery() ([#9722](https://github.com/windmill-labs/windmill/issues/9722)) ([e16061d](https://github.com/windmill-labs/windmill/commit/e16061df06babeae935a9396de5bdcd46e8119a9))
* re-enforce scoped API token boundaries across handlers ([#9712](https://github.com/windmill-labs/windmill/issues/9712)) ([e19594d](https://github.com/windmill-labs/windmill/commit/e19594df2ad015a0336ade95e04562f5562ec3f6))
## [1.735.0](https://github.com/windmill-labs/windmill/compare/v1.734.0...v1.735.0) (2026-06-22)
@@ -0,0 +1,29 @@
{
"db_name": "PostgreSQL",
"query": "SELECT\n COUNT(*)::bigint AS \"total!\",\n COUNT(*) FILTER (WHERE name = ANY($2::text[]))::bigint AS \"replacing!\"\n FROM ai_skill\n WHERE workspace_id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "total!",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "replacing!",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Text",
"TextArray"
]
},
"nullable": [
null,
null
]
},
"hash": "002a606e71364b0581dbc496bf4337f276861dc71d2e277a7aef711543eb14d7"
}
@@ -0,0 +1,35 @@
{
"db_name": "PostgreSQL",
"query": "SELECT kind::text as \"kind!\", parent_job, runnable_path\n FROM v2_job WHERE id = $1 AND workspace_id = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "kind!",
"type_info": "Text"
},
{
"ordinal": 1,
"name": "parent_job",
"type_info": "Uuid"
},
{
"ordinal": 2,
"name": "runnable_path",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Uuid",
"Text"
]
},
"nullable": [
null,
true,
true
]
},
"hash": "19f1cb1c7a1974920549917a6392ff0d56f31ca5662062f4a71fed0ccf859cc4"
}
@@ -0,0 +1,12 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM job_perms\n WHERE ctid IN (\n SELECT jp.ctid FROM job_perms jp\n WHERE NOT EXISTS (SELECT 1 FROM v2_job_queue q WHERE q.id = jp.job_id)\n LIMIT 100000\n )",
"describe": {
"columns": [],
"parameters": {
"Left": []
},
"nullable": []
},
"hash": "25ecae25ebc03d6296b0e72482a9201c2bffb0f0f7419b0b598b2786bdb326ab"
}
@@ -1,12 +1,12 @@
{
"db_name": "PostgreSQL",
"query": "SELECT 1",
"query": "SELECT NOT pg_is_in_recovery()",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "?column?",
"type_info": "Int4"
"type_info": "Bool"
}
],
"parameters": {
@@ -16,5 +16,5 @@
null
]
},
"hash": "e004ebd5b5532a4b85984a62f8ad48a81aa3460c1ca07701f386135d72cdecf5"
"hash": "282b56cfb8504312ac586cd4c1f3f914cf1651c08b8edd1b06d9a6454ed779bf"
}
@@ -1,20 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM job_result_stream_v2\n WHERE job_id NOT IN (SELECT id FROM v2_job_queue)\n AND job_id NOT IN (\n SELECT id FROM v2_job_completed\n WHERE completed_at > NOW() - INTERVAL '60 seconds'\n )\n RETURNING job_id",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "job_id",
"type_info": "Uuid"
}
],
"parameters": {
"Left": []
},
"nullable": [
false
]
},
"hash": "454a611a5a162b2ace137c139bd5383bc7fe142c515dac3edd47991839485e51"
}
@@ -0,0 +1,18 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO ai_skill (workspace_id, name, description, instructions, edited_at, edited_by)\n VALUES ($1, $2, $3, $4, now(), $5)\n ON CONFLICT (workspace_id, name) DO UPDATE\n SET description = EXCLUDED.description,\n instructions = EXCLUDED.instructions,\n edited_at = now(),\n edited_by = EXCLUDED.edited_by",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Varchar",
"Text",
"Text",
"Varchar"
]
},
"nullable": []
},
"hash": "734781e8e55e95c55f72e094e96297aa852e20a0f0d20db4b993947792f6b0a8"
}
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "SELECT runnable_path FROM v2_job WHERE id = $1 AND workspace_id = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "runnable_path",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Uuid",
"Text"
]
},
"nullable": [
true
]
},
"hash": "c167db39eeed526449dc064eaeb26e648aa9455cb81bab86fd106f76537701ba"
}
@@ -1,20 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM job_perms\nWHERE job_id NOT IN (SELECT id FROM v2_job_queue)\nRETURNING job_id",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "job_id",
"type_info": "Uuid"
}
],
"parameters": {
"Left": []
},
"nullable": [
false
]
},
"hash": "c825fa5c6e287068aeaad994c0b42b8ad59b9129f032c6b918c27426ab304f2b"
}
@@ -0,0 +1,28 @@
{
"db_name": "PostgreSQL",
"query": "SELECT name, description FROM ai_skill WHERE workspace_id = $1 ORDER BY name",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "name",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "description",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
false
]
},
"hash": "c84087a0669d0b71829b0765c7274ca0a03fb823a781fb46d2b2b6cfc535a16b"
}
@@ -0,0 +1,12 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM job_result_stream_v2\n WHERE ctid IN (\n SELECT jrs.ctid FROM job_result_stream_v2 jrs\n WHERE NOT EXISTS (SELECT 1 FROM v2_job_queue q WHERE q.id = jrs.job_id)\n AND NOT EXISTS (\n SELECT 1 FROM v2_job_completed c\n WHERE c.id = jrs.job_id\n AND c.completed_at > NOW() - INTERVAL '60 seconds'\n )\n LIMIT 100000\n )",
"describe": {
"columns": [],
"parameters": {
"Left": []
},
"nullable": []
},
"hash": "d059b8a3771e4ac4cd07990ecca84daaed616dc1ac609a6ca81bcd446e4dc230"
}
@@ -0,0 +1,35 @@
{
"db_name": "PostgreSQL",
"query": "SELECT a.policy::text as policy, a.versions[array_upper(a.versions, 1)] as version, av.raw_app as raw_app\n FROM app a JOIN app_version av ON av.id = a.versions[array_upper(a.versions, 1)]\n WHERE a.path = $1 AND a.workspace_id = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "policy",
"type_info": "Text"
},
{
"ordinal": 1,
"name": "version",
"type_info": "Int8"
},
{
"ordinal": 2,
"name": "raw_app",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
null,
null,
false
]
},
"hash": "e0a40d2aba02bd6c502d746471c9c14db8fcaaaf8e3c44fb5ea4ed763a1849dd"
}
@@ -0,0 +1,35 @@
{
"db_name": "PostgreSQL",
"query": "SELECT name, description, instructions FROM ai_skill WHERE workspace_id = $1 AND name = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "name",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "description",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "instructions",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
false,
false,
false
]
},
"hash": "e50afd5156b07e550202fb9b33354dce71b37f89f68d78577b250979daa1a87d"
}
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM ai_skill WHERE workspace_id = $1 AND name = $2 RETURNING name",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "name",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
false
]
},
"hash": "e99fe5cd3283f1701d3a361ef31869da89fd10099b76669b9526201c85f71f61"
}
@@ -0,0 +1,41 @@
{
"db_name": "PostgreSQL",
"query": "SELECT a.path, a.policy::text as policy, a.versions[array_upper(a.versions, 1)] as version, av.raw_app as raw_app\n FROM app a JOIN app_version av ON av.id = a.versions[array_upper(a.versions, 1)]\n WHERE a.id = $1 AND a.workspace_id = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "path",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "policy",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "version",
"type_info": "Int8"
},
{
"ordinal": 3,
"name": "raw_app",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Int8",
"Text"
]
},
"nullable": [
false,
null,
null,
false
]
},
"hash": "ef9caf3da759ee6059922632cdf1fdf5c006554a70a322ca8d3449cdba7840db"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT EXISTS (\n SELECT 1 FROM v2_job_completed c JOIN v2_job j USING (id)\n WHERE j.workspace_id = $2\n AND (j.kind = 'appscript' OR j.kind = 'preview')\n AND j.created_by = 'anonymous'\n AND c.started_at > now() - interval '3 hours'\n AND j.runnable_path LIKE $3 || '/%'\n AND c.result @> ('{\"s3\":\"' || $1 || '\"}')::jsonb\n )",
"query": "SELECT EXISTS (\n SELECT 1 FROM v2_job_completed c JOIN v2_job j USING (id)\n WHERE j.workspace_id = $2\n AND (j.kind = 'appscript' OR j.kind = 'preview')\n AND j.created_by = $4\n AND c.started_at > now() - interval '3 hours'\n AND j.runnable_path LIKE $3 || '/%'\n AND c.result @> ('{\"s3\":\"' || $1 || '\"}')::jsonb\n )",
"describe": {
"columns": [
{
@@ -11,6 +11,7 @@
],
"parameters": {
"Left": [
"Text",
"Text",
"Text",
"Text"
@@ -20,5 +21,5 @@
null
]
},
"hash": "a72e7fb55d7268fe1ea40015a4a84b12dd961ba732772d8f6c59d18fe05f285d"
"hash": "f2760b688a907e7679106aaa2c2063385785f7c2e97364bc04392df11cf364d7"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM v2_job_completed\n WHERE id IN (\n SELECT jc.id FROM v2_job_completed jc\n LEFT JOIN v2_job j ON j.id = jc.id\n WHERE jc.completed_at <= now() - ($1::bigint::text || ' s')::interval\n AND COALESCE(j.root_job, j.flow_innermost_root_job, jc.id) != ALL($3)\n ORDER BY jc.completed_at ASC\n LIMIT $2\n FOR UPDATE OF jc SKIP LOCKED\n )\n RETURNING id",
"query": "DELETE FROM v2_job_completed\n WHERE id IN (\n SELECT jc.id FROM v2_job_completed jc\n LEFT JOIN v2_job j ON j.id = jc.id\n WHERE jc.completed_at <= now() - ($1::bigint::text || ' s')::interval\n AND COALESCE(j.root_job, j.flow_innermost_root_job, jc.id) NOT IN (\n SELECT u FROM unnest($3::uuid[]) AS u WHERE u IS NOT NULL\n )\n ORDER BY jc.completed_at ASC\n LIMIT $2\n FOR UPDATE OF jc SKIP LOCKED\n )\n RETURNING id",
"describe": {
"columns": [
{
@@ -20,5 +20,5 @@
false
]
},
"hash": "45997fcb4d9d62c7f7011966bf59bdb86e12ce1d0c8e925e738d2645121a5c1f"
"hash": "fbe3a876efd1253d2ef086b03366b2bd117ceb6bc152d2abcd45850ff6aecff9"
}
+82 -80
View File
@@ -7046,9 +7046,9 @@ checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4"
[[package]]
name = "memmap2"
version = "0.9.10"
version = "0.9.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "714098028fe011992e1c3962653c96b2d578c4b4bce9036e15ff220319b1e0e3"
checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0"
dependencies = [
"libc",
"stable_deref_trait",
@@ -13735,7 +13735,7 @@ dependencies = [
[[package]]
name = "windmill"
version = "1.735.0"
version = "1.737.0"
dependencies = [
"anyhow",
"async-nats",
@@ -13817,7 +13817,7 @@ dependencies = [
[[package]]
name = "windmill-ai"
version = "1.735.0"
version = "1.737.0"
dependencies = [
"async-stream",
"async-trait",
@@ -13850,7 +13850,7 @@ dependencies = [
[[package]]
name = "windmill-alerting"
version = "1.735.0"
version = "1.737.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -13863,7 +13863,7 @@ dependencies = [
[[package]]
name = "windmill-api"
version = "1.735.0"
version = "1.737.0"
dependencies = [
"anyhow",
"argon2",
@@ -14001,7 +14001,7 @@ dependencies = [
[[package]]
name = "windmill-api-agent-workers"
version = "1.735.0"
version = "1.737.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -14024,7 +14024,7 @@ dependencies = [
[[package]]
name = "windmill-api-assets"
version = "1.735.0"
version = "1.737.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -14037,7 +14037,7 @@ dependencies = [
[[package]]
name = "windmill-api-auth"
version = "1.735.0"
version = "1.737.0"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -14063,7 +14063,7 @@ dependencies = [
[[package]]
name = "windmill-api-client"
version = "1.735.0"
version = "1.737.0"
dependencies = [
"reqwest 0.12.28",
"serde",
@@ -14073,7 +14073,7 @@ dependencies = [
[[package]]
name = "windmill-api-configs"
version = "1.735.0"
version = "1.737.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -14090,7 +14090,7 @@ dependencies = [
[[package]]
name = "windmill-api-debug"
version = "1.735.0"
version = "1.737.0"
dependencies = [
"axum 0.8.9",
"base64 0.22.1",
@@ -14112,7 +14112,7 @@ dependencies = [
[[package]]
name = "windmill-api-embeddings"
version = "1.735.0"
version = "1.737.0"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -14135,7 +14135,7 @@ dependencies = [
[[package]]
name = "windmill-api-flow-conversations"
version = "1.735.0"
version = "1.737.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -14151,7 +14151,7 @@ dependencies = [
[[package]]
name = "windmill-api-flows"
version = "1.735.0"
version = "1.737.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -14172,7 +14172,7 @@ dependencies = [
[[package]]
name = "windmill-api-groups"
version = "1.735.0"
version = "1.737.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -14193,7 +14193,7 @@ dependencies = [
[[package]]
name = "windmill-api-inputs"
version = "1.735.0"
version = "1.737.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -14207,7 +14207,7 @@ dependencies = [
[[package]]
name = "windmill-api-integration-tests"
version = "1.735.0"
version = "1.737.0"
dependencies = [
"anyhow",
"async-nats",
@@ -14242,7 +14242,7 @@ dependencies = [
[[package]]
name = "windmill-api-jobs"
version = "1.735.0"
version = "1.737.0"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -14267,7 +14267,7 @@ dependencies = [
[[package]]
name = "windmill-api-npm-proxy"
version = "1.735.0"
version = "1.737.0"
dependencies = [
"axum 0.8.9",
"flate2",
@@ -14285,7 +14285,7 @@ dependencies = [
[[package]]
name = "windmill-api-openapi"
version = "1.735.0"
version = "1.737.0"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -14307,7 +14307,7 @@ dependencies = [
[[package]]
name = "windmill-api-schedule"
version = "1.735.0"
version = "1.737.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -14327,7 +14327,7 @@ dependencies = [
[[package]]
name = "windmill-api-scripts"
version = "1.735.0"
version = "1.737.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -14364,7 +14364,7 @@ dependencies = [
[[package]]
name = "windmill-api-settings"
version = "1.735.0"
version = "1.737.0"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -14392,7 +14392,7 @@ dependencies = [
[[package]]
name = "windmill-api-sse"
version = "1.735.0"
version = "1.737.0"
dependencies = [
"lazy_static",
"serde",
@@ -14404,7 +14404,7 @@ dependencies = [
[[package]]
name = "windmill-api-users"
version = "1.735.0"
version = "1.737.0"
dependencies = [
"argon2",
"axum 0.8.9",
@@ -14429,7 +14429,7 @@ dependencies = [
[[package]]
name = "windmill-api-workers"
version = "1.735.0"
version = "1.737.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -14443,7 +14443,7 @@ dependencies = [
[[package]]
name = "windmill-api-workspaces"
version = "1.735.0"
version = "1.737.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -14476,7 +14476,7 @@ dependencies = [
[[package]]
name = "windmill-audit"
version = "1.735.0"
version = "1.737.0"
dependencies = [
"chrono",
"lazy_static",
@@ -14490,7 +14490,7 @@ dependencies = [
[[package]]
name = "windmill-autoscaling"
version = "1.735.0"
version = "1.737.0"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -14509,7 +14509,7 @@ dependencies = [
[[package]]
name = "windmill-common"
version = "1.735.0"
version = "1.737.0"
dependencies = [
"aes-gcm",
"aho-corasick",
@@ -14611,7 +14611,7 @@ dependencies = [
[[package]]
name = "windmill-dep-map"
version = "1.735.0"
version = "1.737.0"
dependencies = [
"chrono",
"itertools 0.14.0",
@@ -14630,7 +14630,7 @@ dependencies = [
[[package]]
name = "windmill-git-sync"
version = "1.735.0"
version = "1.737.0"
dependencies = [
"regex",
"serde",
@@ -14645,7 +14645,7 @@ dependencies = [
[[package]]
name = "windmill-indexer"
version = "1.735.0"
version = "1.737.0"
dependencies = [
"anyhow",
"astral-tokio-tar",
@@ -14669,7 +14669,7 @@ dependencies = [
[[package]]
name = "windmill-jseval"
version = "1.735.0"
version = "1.737.0"
dependencies = [
"anyhow",
"futures",
@@ -14686,7 +14686,7 @@ dependencies = [
[[package]]
name = "windmill-macros"
version = "1.735.0"
version = "1.737.0"
dependencies = [
"itertools 0.14.0",
"lazy_static",
@@ -14702,7 +14702,7 @@ dependencies = [
[[package]]
name = "windmill-mcp"
version = "1.735.0"
version = "1.737.0"
dependencies = [
"anyhow",
"async-trait",
@@ -14723,7 +14723,7 @@ dependencies = [
[[package]]
name = "windmill-native-triggers"
version = "1.735.0"
version = "1.737.0"
dependencies = [
"anyhow",
"async-trait",
@@ -14754,7 +14754,7 @@ dependencies = [
[[package]]
name = "windmill-oauth"
version = "1.735.0"
version = "1.737.0"
dependencies = [
"anyhow",
"arc-swap",
@@ -14779,7 +14779,7 @@ dependencies = [
[[package]]
name = "windmill-object-store"
version = "1.735.0"
version = "1.737.0"
dependencies = [
"anyhow",
"async-stream",
@@ -14813,7 +14813,7 @@ dependencies = [
[[package]]
name = "windmill-operator"
version = "1.735.0"
version = "1.737.0"
dependencies = [
"anyhow",
"futures",
@@ -14831,7 +14831,7 @@ dependencies = [
[[package]]
name = "windmill-parser"
version = "1.735.0"
version = "1.737.0"
dependencies = [
"convert_case 0.6.0",
"serde",
@@ -14840,7 +14840,7 @@ dependencies = [
[[package]]
name = "windmill-parser-bash"
version = "1.735.0"
version = "1.737.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -14852,7 +14852,7 @@ dependencies = [
[[package]]
name = "windmill-parser-csharp"
version = "1.735.0"
version = "1.737.0"
dependencies = [
"anyhow",
"serde_json",
@@ -14864,7 +14864,7 @@ dependencies = [
[[package]]
name = "windmill-parser-go"
version = "1.735.0"
version = "1.737.0"
dependencies = [
"anyhow",
"gosyn",
@@ -14876,7 +14876,7 @@ dependencies = [
[[package]]
name = "windmill-parser-graphql"
version = "1.735.0"
version = "1.737.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -14888,7 +14888,7 @@ dependencies = [
[[package]]
name = "windmill-parser-java"
version = "1.735.0"
version = "1.737.0"
dependencies = [
"anyhow",
"serde_json",
@@ -14900,7 +14900,7 @@ dependencies = [
[[package]]
name = "windmill-parser-nu"
version = "1.735.0"
version = "1.737.0"
dependencies = [
"anyhow",
"nu-parser",
@@ -14911,7 +14911,7 @@ dependencies = [
[[package]]
name = "windmill-parser-php"
version = "1.735.0"
version = "1.737.0"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -14922,7 +14922,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py"
version = "1.735.0"
version = "1.737.0"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -14934,7 +14934,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-asset"
version = "1.735.0"
version = "1.737.0"
dependencies = [
"anyhow",
"rustpython-ast",
@@ -14945,7 +14945,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-imports"
version = "1.735.0"
version = "1.737.0"
dependencies = [
"anyhow",
"async-recursion",
@@ -14967,7 +14967,7 @@ dependencies = [
[[package]]
name = "windmill-parser-r"
version = "1.735.0"
version = "1.737.0"
dependencies = [
"anyhow",
"serde_json",
@@ -14979,7 +14979,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ruby"
version = "1.735.0"
version = "1.737.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -14993,7 +14993,7 @@ dependencies = [
[[package]]
name = "windmill-parser-rust"
version = "1.735.0"
version = "1.737.0"
dependencies = [
"anyhow",
"convert_case 0.6.0",
@@ -15010,7 +15010,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql"
version = "1.735.0"
version = "1.737.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -15023,7 +15023,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql-asset"
version = "1.735.0"
version = "1.737.0"
dependencies = [
"anyhow",
"serde",
@@ -15035,7 +15035,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts"
version = "1.735.0"
version = "1.737.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -15053,7 +15053,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts-asset"
version = "1.735.0"
version = "1.737.0"
dependencies = [
"anyhow",
"serde-wasm-bindgen",
@@ -15069,7 +15069,7 @@ dependencies = [
[[package]]
name = "windmill-parser-wac"
version = "1.735.0"
version = "1.737.0"
dependencies = [
"anyhow",
"rustpython-ast",
@@ -15085,7 +15085,7 @@ dependencies = [
[[package]]
name = "windmill-parser-yaml"
version = "1.735.0"
version = "1.737.0"
dependencies = [
"anyhow",
"serde",
@@ -15096,7 +15096,7 @@ dependencies = [
[[package]]
name = "windmill-queue"
version = "1.735.0"
version = "1.737.0"
dependencies = [
"anyhow",
"async-recursion",
@@ -15134,7 +15134,7 @@ dependencies = [
[[package]]
name = "windmill-runtime-nativets"
version = "1.735.0"
version = "1.737.0"
dependencies = [
"anyhow",
"const_format",
@@ -15173,7 +15173,7 @@ dependencies = [
[[package]]
name = "windmill-sql-datatype-parser-wasm"
version = "1.735.0"
version = "1.737.0"
dependencies = [
"getrandom 0.3.4",
"wasm-bindgen",
@@ -15184,17 +15184,19 @@ dependencies = [
[[package]]
name = "windmill-store"
version = "1.735.0"
version = "1.737.0"
dependencies = [
"anyhow",
"async-recursion",
"axum 0.8.9",
"base64 0.22.1",
"chrono",
"futures",
"hex",
"http 1.4.2",
"hyper 1.10.1",
"lazy_static",
"magic-crypt",
"quick_cache",
"reqwest 0.13.1",
"serde",
@@ -15216,7 +15218,7 @@ dependencies = [
[[package]]
name = "windmill-test-utils"
version = "1.735.0"
version = "1.737.0"
dependencies = [
"anyhow",
"async-trait",
@@ -15240,7 +15242,7 @@ dependencies = [
[[package]]
name = "windmill-trigger"
version = "1.735.0"
version = "1.737.0"
dependencies = [
"anyhow",
"async-trait",
@@ -15273,7 +15275,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-azure"
version = "1.735.0"
version = "1.737.0"
dependencies = [
"anyhow",
"async-trait",
@@ -15306,7 +15308,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-email"
version = "1.735.0"
version = "1.737.0"
dependencies = [
"anyhow",
"async-trait",
@@ -15326,7 +15328,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-gcp"
version = "1.735.0"
version = "1.737.0"
dependencies = [
"anyhow",
"async-trait",
@@ -15360,7 +15362,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-http"
version = "1.735.0"
version = "1.737.0"
dependencies = [
"anyhow",
"async-trait",
@@ -15396,7 +15398,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-kafka"
version = "1.735.0"
version = "1.737.0"
dependencies = [
"anyhow",
"async-trait",
@@ -15419,7 +15421,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-mqtt"
version = "1.735.0"
version = "1.737.0"
dependencies = [
"anyhow",
"async-trait",
@@ -15443,7 +15445,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-nats"
version = "1.735.0"
version = "1.737.0"
dependencies = [
"anyhow",
"async-nats",
@@ -15467,7 +15469,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-postgres"
version = "1.735.0"
version = "1.737.0"
dependencies = [
"anyhow",
"async-trait",
@@ -15502,7 +15504,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-sqs"
version = "1.735.0"
version = "1.737.0"
dependencies = [
"anyhow",
"async-trait",
@@ -15530,7 +15532,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-websocket"
version = "1.735.0"
version = "1.737.0"
dependencies = [
"anyhow",
"async-trait",
@@ -15555,7 +15557,7 @@ dependencies = [
[[package]]
name = "windmill-types"
version = "1.735.0"
version = "1.737.0"
dependencies = [
"anyhow",
"bitflags 2.13.0",
@@ -15574,7 +15576,7 @@ dependencies = [
[[package]]
name = "windmill-worker"
version = "1.735.0"
version = "1.737.0"
dependencies = [
"anyhow",
"async-once-cell",
@@ -15684,7 +15686,7 @@ dependencies = [
[[package]]
name = "windmill-worker-volumes"
version = "1.735.0"
version = "1.737.0"
dependencies = [
"bytes",
"futures",
+2 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "windmill"
version = "1.735.0"
version = "1.737.0"
authors.workspace = true
edition.workspace = true
@@ -87,7 +87,7 @@ members = [
exclude = ["./windmill-duckdb-ffi-internal", "./parsers/windmill-parser-wasm"]
[workspace.package]
version = "1.735.0"
version = "1.737.0"
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
edition = "2021"
+1 -1
View File
@@ -1 +1 @@
de49fda2320504ad9e7d2d31c7033d71dbf6ca43
ac1f6f666f36141cb6ba6f8eaa614821a90464ad
@@ -0,0 +1 @@
DROP TABLE IF EXISTS ai_skill;
@@ -0,0 +1,15 @@
-- Workspace-scoped AI chat skills (Claude/Codex-style SKILL.md instructions).
-- `name` is the skill folder slug; `description` is advertised in the AI chat
-- system prompt, `instructions` is the SKILL.md body fetched on demand.
CREATE TABLE ai_skill (
workspace_id VARCHAR(50) NOT NULL REFERENCES workspace(id) ON DELETE CASCADE,
name VARCHAR(255) NOT NULL,
description TEXT NOT NULL,
instructions TEXT NOT NULL,
edited_at TIMESTAMPTZ NOT NULL DEFAULT now(),
edited_by VARCHAR(255) NOT NULL DEFAULT '',
PRIMARY KEY (workspace_id, name)
);
GRANT ALL ON ai_skill TO windmill_user;
GRANT ALL ON ai_skill TO windmill_admin;
+24 -24
View File
@@ -6191,7 +6191,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windmill-common"
version = "1.735.0"
version = "1.737.0"
dependencies = [
"aho-corasick",
"anyhow",
@@ -6272,7 +6272,7 @@ dependencies = [
[[package]]
name = "windmill-macros"
version = "1.735.0"
version = "1.737.0"
dependencies = [
"proc-macro2",
"quote",
@@ -6284,7 +6284,7 @@ dependencies = [
[[package]]
name = "windmill-parser"
version = "1.735.0"
version = "1.737.0"
dependencies = [
"convert_case",
"serde",
@@ -6293,7 +6293,7 @@ dependencies = [
[[package]]
name = "windmill-parser-bash"
version = "1.735.0"
version = "1.737.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6305,7 +6305,7 @@ dependencies = [
[[package]]
name = "windmill-parser-csharp"
version = "1.735.0"
version = "1.737.0"
dependencies = [
"anyhow",
"serde_json",
@@ -6317,7 +6317,7 @@ dependencies = [
[[package]]
name = "windmill-parser-go"
version = "1.735.0"
version = "1.737.0"
dependencies = [
"anyhow",
"gosyn",
@@ -6329,7 +6329,7 @@ dependencies = [
[[package]]
name = "windmill-parser-graphql"
version = "1.735.0"
version = "1.737.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6341,7 +6341,7 @@ dependencies = [
[[package]]
name = "windmill-parser-java"
version = "1.735.0"
version = "1.737.0"
dependencies = [
"anyhow",
"serde_json",
@@ -6353,7 +6353,7 @@ dependencies = [
[[package]]
name = "windmill-parser-nu"
version = "1.735.0"
version = "1.737.0"
dependencies = [
"anyhow",
"nu-parser",
@@ -6364,7 +6364,7 @@ dependencies = [
[[package]]
name = "windmill-parser-php"
version = "1.735.0"
version = "1.737.0"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -6375,7 +6375,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py"
version = "1.735.0"
version = "1.737.0"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -6387,7 +6387,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-asset"
version = "1.735.0"
version = "1.737.0"
dependencies = [
"anyhow",
"rustpython-ast",
@@ -6398,7 +6398,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-imports"
version = "1.735.0"
version = "1.737.0"
dependencies = [
"anyhow",
"async-recursion",
@@ -6420,7 +6420,7 @@ dependencies = [
[[package]]
name = "windmill-parser-r"
version = "1.735.0"
version = "1.737.0"
dependencies = [
"anyhow",
"serde_json",
@@ -6432,7 +6432,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ruby"
version = "1.735.0"
version = "1.737.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6446,7 +6446,7 @@ dependencies = [
[[package]]
name = "windmill-parser-rust"
version = "1.735.0"
version = "1.737.0"
dependencies = [
"anyhow",
"convert_case",
@@ -6463,7 +6463,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql"
version = "1.735.0"
version = "1.737.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6476,7 +6476,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql-asset"
version = "1.735.0"
version = "1.737.0"
dependencies = [
"anyhow",
"serde",
@@ -6488,7 +6488,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts"
version = "1.735.0"
version = "1.737.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6506,7 +6506,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts-asset"
version = "1.735.0"
version = "1.737.0"
dependencies = [
"anyhow",
"serde-wasm-bindgen",
@@ -6522,7 +6522,7 @@ dependencies = [
[[package]]
name = "windmill-parser-wac"
version = "1.735.0"
version = "1.737.0"
dependencies = [
"anyhow",
"rustpython-ast",
@@ -6538,7 +6538,7 @@ dependencies = [
[[package]]
name = "windmill-parser-wasm"
version = "1.735.0"
version = "1.737.0"
dependencies = [
"anyhow",
"getrandom 0.2.17",
@@ -6570,7 +6570,7 @@ dependencies = [
[[package]]
name = "windmill-parser-yaml"
version = "1.735.0"
version = "1.737.0"
dependencies = [
"anyhow",
"serde",
@@ -6581,7 +6581,7 @@ dependencies = [
[[package]]
name = "windmill-types"
version = "1.735.0"
version = "1.737.0"
dependencies = [
"anyhow",
"bitflags",
@@ -12,7 +12,7 @@ resolver = "2"
members = ["."]
[workspace.package]
version = "1.735.0"
version = "1.737.0"
edition = "2021"
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
+38 -12
View File
@@ -1446,19 +1446,45 @@ Windmill Community Edition {GIT_VERSION}
} else {
None
};
monitor_db(
&conn,
&base_internal_url,
server_mode,
worker_mode,
false,
tx.clone(),
Some(MonitorIteration {
rd_shift,
iter: monitor_iteration,
}),
// Hard cap on a single monitor pass. monitor_db runs all its
// periodic tasks under one join!, so a single task stuck on a
// non-DB await (statement_timeout only bounds DB statements)
// would otherwise freeze the whole loop indefinitely — silently
// stopping critical maintenance like audit-partition creation.
// Larger than statement_timeout (5min) so a slow-but-progressing
// statement is never killed prematurely.
const MONITOR_DB_TIMEOUT: Duration = Duration::from_secs(600);
let monitor_timed_out = tokio::time::timeout(
MONITOR_DB_TIMEOUT,
monitor_db(
&conn,
&base_internal_url,
server_mode,
worker_mode,
false,
tx.clone(),
Some(MonitorIteration {
rd_shift,
iter: monitor_iteration,
}),
),
)
.await;
.await
.is_err();
if monitor_timed_out {
windmill_common::utils::report_critical_error(
format!(
"monitor task did not finish within {}s and was aborted; \
a background maintenance task is likely stuck. \
Continuing to the next iteration.",
MONITOR_DB_TIMEOUT.as_secs()
),
db.clone(),
None,
None,
)
.await;
}
monitor_iteration += 1;
if let Some(handle) = warn_handle {
handle.abort();
+124 -51
View File
@@ -1532,14 +1532,22 @@ async fn delete_expired_jobs_batch(
.await?;
// Use FOR UPDATE SKIP LOCKED to avoid contention between replicas
// ORDER BY completed_at ensures we delete oldest jobs first
// ORDER BY completed_at ensures we delete oldest jobs first.
// Active-root exclusion uses `NOT IN (SELECT ... unnest($3))` rather than
// `!= ALL($3)`: the subquery form lets the planner build a one-time hashed
// SubPlan and apply it as a filter on the ordered index scan, giving O(1)
// membership per candidate instead of a per-row linear array scan (which
// degrades sharply when many root jobs are active). The `u IS NOT NULL` guard
// sidesteps NOT IN's null-trap semantics ($3 holds non-null PK ids).
let deleted_jobs: Vec<Uuid> = sqlx::query_scalar!(
"DELETE FROM v2_job_completed
WHERE id IN (
SELECT jc.id FROM v2_job_completed jc
LEFT JOIN v2_job j ON j.id = jc.id
WHERE jc.completed_at <= now() - ($1::bigint::text || ' s')::interval
AND COALESCE(j.root_job, j.flow_innermost_root_job, jc.id) != ALL($3)
AND COALESCE(j.root_job, j.flow_innermost_root_job, jc.id) NOT IN (
SELECT u FROM unnest($3::uuid[]) AS u WHERE u IS NOT NULL
)
ORDER BY jc.completed_at ASC
LIMIT $2
FOR UPDATE OF jc SKIP LOCKED
@@ -4368,39 +4376,65 @@ RETURNING key,job_id
Ok(())
}
async fn cleanup_job_perms_orphaned(db: &DB) -> error::Result<()> {
let result = sqlx::query_scalar!(
"DELETE FROM job_perms
WHERE job_id NOT IN (SELECT id FROM v2_job_queue)
RETURNING job_id"
)
.fetch_all(db)
.await?;
// Per-statement cap keeps each delete short and lock-light; the per-cycle batch
// cap bounds total work per monitor iteration so monitor_db stays responsive.
// A large backlog drains across several iterations rather than one long delete.
const ORPHAN_CLEANUP_BATCH_SIZE: u64 = 100_000;
const ORPHAN_CLEANUP_MAX_BATCHES: usize = 10;
if !result.is_empty() {
tracing::info!("Cleaned up {} orphaned job_perms rows", result.len());
async fn cleanup_job_perms_orphaned(db: &DB) -> error::Result<()> {
let mut total: u64 = 0;
for _ in 0..ORPHAN_CLEANUP_MAX_BATCHES {
let count = sqlx::query!(
"DELETE FROM job_perms
WHERE ctid IN (
SELECT jp.ctid FROM job_perms jp
WHERE NOT EXISTS (SELECT 1 FROM v2_job_queue q WHERE q.id = jp.job_id)
LIMIT 100000
)"
)
.execute(db)
.await?
.rows_affected();
total += count;
if count < ORPHAN_CLEANUP_BATCH_SIZE {
break;
}
}
if total > 0 {
tracing::info!("Cleaned up {total} orphaned job_perms rows");
}
Ok(())
}
async fn cleanup_job_result_stream_orphaned_jobs(db: &DB) -> error::Result<()> {
let result = sqlx::query!(
"DELETE FROM job_result_stream_v2
WHERE job_id NOT IN (SELECT id FROM v2_job_queue)
AND job_id NOT IN (
SELECT id FROM v2_job_completed
WHERE completed_at > NOW() - INTERVAL '60 seconds'
)
RETURNING job_id",
)
.fetch_all(db)
.await?;
let mut total: u64 = 0;
for _ in 0..ORPHAN_CLEANUP_MAX_BATCHES {
let count = sqlx::query!(
"DELETE FROM job_result_stream_v2
WHERE ctid IN (
SELECT jrs.ctid FROM job_result_stream_v2 jrs
WHERE NOT EXISTS (SELECT 1 FROM v2_job_queue q WHERE q.id = jrs.job_id)
AND NOT EXISTS (
SELECT 1 FROM v2_job_completed c
WHERE c.id = jrs.job_id
AND c.completed_at > NOW() - INTERVAL '60 seconds'
)
LIMIT 100000
)",
)
.execute(db)
.await?
.rows_affected();
total += count;
if count < ORPHAN_CLEANUP_BATCH_SIZE {
break;
}
}
if result.len() > 0 {
tracing::info!(
"Cleaned up {} orphaned job_result_stream_v2 rows",
result.len()
);
if total > 0 {
tracing::info!("Cleaned up {total} orphaned job_result_stream_v2 rows");
}
Ok(())
}
@@ -4436,11 +4470,19 @@ async fn audit_log_retention_days() -> i64 {
}
}
/// Number of days ahead (including today) for which an audit partition must
/// always exist. A missing partition in this window means audit inserts fail
/// once that date is reached — and because some callers (notably login) write
/// the audit row in the same transaction as their own work, that failure
/// poisons the whole transaction, so a missing partition is a hard outage, not
/// just a dropped audit row.
const AUDIT_PARTITION_LOOKAHEAD_DAYS: i64 = 3;
async fn manage_audit_partitions(db: &DB, retention_days: i64) {
let today = chrono::Utc::now().date_naive();
// Create partitions for today and the next 3 days
for days_ahead in 0..=3i64 {
// Create partitions for today and the next few days
for days_ahead in 0..=AUDIT_PARTITION_LOOKAHEAD_DAYS {
let date = today + chrono::Duration::days(days_ahead);
let next_date = date + chrono::Duration::days(1);
let partition_name = format!("audit_{}", date.format("%Y%m%d"));
@@ -4456,9 +4498,6 @@ async fn manage_audit_partitions(db: &DB, retention_days: i64) {
}
}
// Drop expired partitions
let cutoff_date = today - chrono::Duration::days(retention_days);
let partitions = sqlx::query_scalar::<_, String>(
"SELECT c.relname::text \
FROM pg_inherits i \
@@ -4468,28 +4507,62 @@ async fn manage_audit_partitions(db: &DB, retention_days: i64) {
.fetch_all(db)
.await;
match partitions {
Ok(partitions) => {
for partition_name in partitions {
if let Some(date_str) = partition_name.strip_prefix("audit_") {
if let Ok(date) = chrono::NaiveDate::parse_from_str(date_str, "%Y%m%d") {
if date < cutoff_date {
let quoted_name =
format!("\"{}\"", partition_name.replace('"', "\"\""));
let sql = format!("DROP TABLE IF EXISTS {quoted_name}");
match sqlx::query(&sql).execute(db).await {
Ok(_) => tracing::info!(
"Dropped expired audit partition {partition_name}"
),
Err(e) => tracing::error!(
"Error dropping audit partition {partition_name}: {e:?}"
),
}
let partitions = match partitions {
Ok(partitions) => partitions,
Err(e) => {
tracing::error!("Error listing audit partitions: {e:?}");
return;
}
};
// Verify the lookahead window is actually covered. If a create above failed
// (or this loop has not run for several days), alert loudly instead of
// letting it surface days later as failed audit inserts and broken logins.
let existing: std::collections::HashSet<&str> = partitions.iter().map(|s| s.as_str()).collect();
let missing: Vec<String> = (0..=AUDIT_PARTITION_LOOKAHEAD_DAYS)
.map(|days_ahead| {
format!(
"audit_{}",
(today + chrono::Duration::days(days_ahead)).format("%Y%m%d")
)
})
.filter(|name| !existing.contains(name.as_str()))
.collect();
if !missing.is_empty() {
report_critical_error(
format!(
"Audit log partitions missing after maintenance run: {}. \
Audit inserts will fail once these dates are reached, which also \
breaks logins (the login audit row shares the login transaction). \
Check for earlier 'Error creating audit partition' logs and verify \
the audit-partition maintenance loop is still running.",
missing.join(", ")
),
db.clone(),
None,
None,
)
.await;
}
// Drop expired partitions
let cutoff_date = today - chrono::Duration::days(retention_days);
for partition_name in &partitions {
if let Some(date_str) = partition_name.strip_prefix("audit_") {
if let Ok(date) = chrono::NaiveDate::parse_from_str(date_str, "%Y%m%d") {
if date < cutoff_date {
let quoted_name = format!("\"{}\"", partition_name.replace('"', "\"\""));
let sql = format!("DROP TABLE IF EXISTS {quoted_name}");
match sqlx::query(&sql).execute(db).await {
Ok(_) => {
tracing::info!("Dropped expired audit partition {partition_name}")
}
Err(e) => tracing::error!(
"Error dropping audit partition {partition_name}: {e:?}"
),
}
}
}
}
Err(e) => tracing::error!("Error listing audit partitions: {e:?}"),
}
}
+39
View File
@@ -19,6 +19,45 @@ INSERT INTO token(token_hash, token_prefix, token, email, label, super_admin, sc
ARRAY['jobs:read', 'if_jobs:filter_tags:deno']
);
-- App embed token for the admin viewer (test-user). Mirrors a minted sandboxed
-- low-code app token: carries the `app_embed` sentinel plus the embed scope set.
-- Used to assert the token is confined to jobs the viewer LAUNCHED, not every job
-- the (admin) viewer could otherwise read.
INSERT INTO token(token_hash, token_prefix, token, email, label, super_admin, scopes) VALUES (
encode(sha256('EMBED_APP_TOKEN'::bytea), 'hex'), 'EMBED_APP_', 'EMBED_APP_TOKEN',
'test@windmill.dev', 'app embed token', false,
ARRAY['apps:run', 'jobs:read', 'app_embed', 'resources:run', 'users:read', 'folders:read']
);
-- A completed app-component job LAUNCHED BY the admin viewer (created_by =
-- test-user), running as the app owner. The embed token must keep reading its own
-- launched job (the `created_by == viewer` fast path).
INSERT INTO public.v2_job (
id, workspace_id, created_by, created_at, permissioned_as, permissioned_as_email,
kind, script_lang, runnable_path, tag, visible_to_owner, args
) VALUES (
'12121212-1212-1212-1212-121212121212', 'test-workspace', 'test-user',
'2023-01-01 00:00:00', 'u/test-user-2', 'test2@windmill.dev',
'script', 'deno', 'u/test-user-2/app_component', 'deno', false,
'{"own": "arg"}'
);
INSERT INTO public.v2_job_completed (id, workspace_id, duration_ms, status, result) VALUES
('12121212-1212-1212-1212-121212121212', 'test-workspace', 1000, 'success'::job_status,
'{"own": "EMBED_OWN_RESULT"}');
-- A QUEUED job launched by the admin embed viewer (created_by = test-user). The
-- embed token may cancel its own launched job; it must NOT cancel another user's.
INSERT INTO public.v2_job (
id, workspace_id, created_by, created_at, permissioned_as, permissioned_as_email,
kind, script_lang, runnable_path, tag, visible_to_owner
) VALUES (
'13131313-1313-1313-1313-131313131313', 'test-workspace', 'test-user',
'2023-01-01 00:00:00', 'u/test-user-2', 'test2@windmill.dev',
'script', 'deno', 'u/test-user-2/app_component', 'deno', false
);
INSERT INTO public.v2_job_queue (id, workspace_id, scheduled_for, running, tag) VALUES
('13131313-1313-1313-1313-131313131313', 'test-workspace', '2023-01-01 00:00:00', false, 'deno');
-- RUNNING job: queued (no completed row) and owned by test-user-2. Used to check
-- that `completed/get_result_maybe?get_started=true` authorizes before disclosing
-- running-state to a non-reader.
+86
View File
@@ -38,6 +38,10 @@ const TOP_SECRET_FLOW: &str = "ffffffff-ffff-ffff-ffff-ffffffffffff";
const DEEP_LEAF_JOB: &str = "88888888-8888-8888-8888-888888888888";
// A queued/running job (no completed row) owned by test-user-2.
const RUNNING_JOB: &str = "77777777-7777-7777-7777-777777777777";
// An app-component job launched BY the admin embed viewer (created_by test-user).
const EMBED_OWN_JOB: &str = "12121212-1212-1212-1212-121212121212";
// A QUEUED job launched by the embed viewer (created_by test-user) — cancelable by it.
const EMBED_OWN_QUEUED: &str = "13131313-1313-1313-1313-131313131313";
// Secrets that must never leak to an unauthorized viewer.
const RESULT_SECRET: &str = "RESULT_SECRET";
@@ -59,6 +63,19 @@ async fn get(base: &str, path: &str, token: Option<&str>) -> (reqwest::StatusCod
(status, body)
}
async fn post(base: &str, path: &str, token: Option<&str>) -> (reqwest::StatusCode, String) {
let mut req = client()
.post(format!("{base}/{path}"))
.json(&serde_json::json!({}));
if let Some(token) = token {
req = req.header("Authorization", format!("Bearer {token}"));
}
let resp = req.send().await.expect("request");
let status = resp.status();
let body = resp.text().await.expect("body");
(status, body)
}
#[sqlx::test(fixtures("base", "jobs_read_auth"))]
async fn test_single_job_read_authorization(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
@@ -281,6 +298,75 @@ async fn test_single_job_read_authorization(db: Pool<Postgres>) -> anyhow::Resul
"top flow in an unreadable folder must stay denied (got {status}): {body}"
);
// ---- APP EMBED TOKEN: confined to jobs the viewer LAUNCHED, not everything
// the (admin) viewer can otherwise read. The token carries the `app_embed`
// sentinel; an admin's normal token reads VICTIM (asserted above), but the
// embed token must stop at the `created_by == viewer` grant so user-authored
// app JS can't reuse it to read unrelated jobs by UUID.
// Its own launched component job (created_by == viewer) still reads.
let (status, body) = get(
&base,
&format!("completed/get_result/{EMBED_OWN_JOB}"),
Some("EMBED_APP_TOKEN"),
)
.await;
assert!(
status.is_success(),
"embed token must read a job it launched (got {status}): {body}"
);
assert!(
body.contains("EMBED_OWN_RESULT"),
"embed token should get its own launched job result: {body}"
);
// The VICTIM job — created by another user but readable by this admin viewer's
// normal token (asserted above) — is denied to the embed token across result /
// logs / live update. NotFound (not 403) so the untrusted app can't even probe
// existence, and no secret leaks.
for path in [
format!("completed/get_result/{VICTIM}"),
format!("get_logs/{VICTIM}"),
format!("getupdate/{VICTIM}?only_result=true"),
] {
let (status, body) = get(&base, &path, Some("EMBED_APP_TOKEN")).await;
assert_eq!(
status,
reqwest::StatusCode::NOT_FOUND,
"embed token must not read a job it did not launch ({path}, got {status}): {body}"
);
for secret in [RESULT_SECRET, ARGS_SECRET, LOGS_SECRET] {
assert!(
!body.contains(secret),
"embed token response for {path} leaked `{secret}`: {body}"
);
}
}
// ---- APP EMBED TOKEN: cancellation confined to the app's own jobs. The token
// may cancel a job it launched (created_by == viewer), but `cancel_job_api`
// denies (NotFound) a job created by someone else, even though cancel
// otherwise has no per-job ownership check.
let (status, body) = post(
&base,
&format!("queue/cancel/{EMBED_OWN_QUEUED}"),
Some("EMBED_APP_TOKEN"),
)
.await;
assert!(
status.is_success(),
"embed token must cancel a job it launched (got {status}): {body}"
);
let (status, body) = post(
&base,
&format!("queue/cancel/{RUNNING_JOB}"),
Some("EMBED_APP_TOKEN"),
)
.await;
assert_eq!(
status,
reqwest::StatusCode::NOT_FOUND,
"embed token must not cancel another user's job (got {status}): {body}"
);
// ---- UNAUTHENTICATED, unchanged: an anonymous-created job is readable
// without a token (public trigger / public app result polling).
let (status, body) = get(&base, &format!("completed/get_result/{ANON_JOB}"), None).await;
+5 -1
View File
@@ -191,7 +191,11 @@ impl AuthCache {
is_operator: claims.is_operator,
groups: claims.groups,
folders: claims.folders,
scopes: None,
// Honor the scopes embedded in the JWT (mirrors the EE
// jwt_ext_ branch). The route middleware only enforces
// scopes when Some, so a None-scoped JWT (e.g. the job
// WM_TOKEN) keeps full user privileges as before.
scopes: claims.scopes,
username_override,
token_prefix: claims.audit_span,
read_only: false,
+218 -1
View File
@@ -274,6 +274,7 @@ pub enum ScopeDomain {
Configs,
OAuth,
AI,
AiSkills,
Indexer,
Teams, // Microsoft Teams integration
@@ -329,6 +330,7 @@ impl ScopeDomain {
Self::Configs => "configs",
Self::OAuth => "oauth",
Self::AI => "ai",
Self::AiSkills => "ai_skills",
Self::Capture => "capture",
Self::Drafts => "drafts",
Self::Favorites => "favorites",
@@ -378,6 +380,7 @@ impl ScopeDomain {
"configs" => Some(Self::Configs),
"oauth" => Some(Self::OAuth),
"ai" => Some(Self::AI),
"ai_skills" => Some(Self::AiSkills),
"indexer" | "srch" => Some(Self::Indexer),
"teams" => Some(Self::Teams),
"native_triggers" => Some(Self::NativeTriggers),
@@ -448,6 +451,30 @@ pub fn check_route_access(
// Find the domain and kind for this route
let (required_domain, required_kind, route_suffix) = extract_domain_from_route(route_path)?;
// App embed tokens (sentinel) carry broad read scopes (`jobs:read`,
// `users:read`, `folders:read`) that exist only for a handful of routes. The
// whole `/users`, `/folders` and `/jobs` routers are CORS-enabled for the
// opaque app iframe, so default-deny everything in those domains except the
// intended routes — otherwise the token could enumerate/export workspace data.
if has_app_embed_sentinel(Some(token_scopes)) {
if let Some(suffix) = route_suffix.as_deref() {
if app_embed_route_denied(required_domain, suffix) {
return Err(Error::PermissionDenied(
"Access denied. App embed token cannot access this route.".to_string(),
));
}
// The by-id job cancel is a POST (write) that the token's `jobs:read`
// wouldn't satisfy, but cancelling the app's own component runs is
// intended (most components supersede an in-flight run on re-run). Permit
// it here; `cancel_job_api` confines it to jobs the app launched
// (created_by == viewer). A read_only token is still rejected by the
// separate read-only check.
if suffix.starts_with("jobs_u/queue/cancel/") {
return Ok(());
}
}
}
// MCP scopes (mcp:all, mcp:favorites, mcp:hub:*, etc.) use a custom format
// that doesn't fit the standard domain:action model. Verify the token has at
// least one mcp: scope; MCP handlers do their own fine-grained checking.
@@ -534,7 +561,7 @@ const FLOW_JOBS: [&'static str; 6] = [
lazy_static::lazy_static! {
static ref RUN_PATH_ACTIONS: Vec<&'static str> = {
let mut v = vec!["jobs/resume/", "jobs/run/batch_rerun_jobs", "jobs/run/workflow_as_code", "jobs/run/dependencies","jobs/run/flow_dependencies", "apps_u/execute_component"];
let mut v = vec!["jobs/resume/", "jobs/run/batch_rerun_jobs", "jobs/run/workflow_as_code", "jobs/run/dependencies","jobs/run/flow_dependencies", "apps_u/execute_component", "apps_u/upload_s3_file"];
v.extend(SCRIPT_JOBS);
v.extend(FLOW_JOBS);
@@ -637,6 +664,92 @@ const RUN_WHITELISTED_GET_PATHS: [&'static str; 20] = [
"jobs/completed/get_result_maybe/",
];
/// Sentinel scope in app embed tokens. Grants nothing itself; `check_route_access`
/// uses it to deny the workspace-wide job enumeration routes `jobs:read` would
/// otherwise reach, so an embedded app reads only jobs it launched (by id).
pub const APP_EMBED_SENTINEL: &str = "app_embed";
/// True if a token's scopes include the app-embed sentinel (a sandboxed app iframe
/// token). Such tokens carry the viewer's identity but represent untrusted app JS,
/// so several handlers confine them to the app's own resources/runs.
pub fn has_app_embed_sentinel(scopes: Option<&[String]>) -> bool {
scopes.is_some_and(|s| s.iter().any(|x| x == APP_EMBED_SENTINEL))
}
/// Routes an app embed token (sentinel) is denied. Its broad scopes (`apps:run`,
/// `jobs:read`, `users:read`, `folders:read`) exist only for a fixed set of routes a
/// running app uses, but the whole `/apps`, `/jobs`, `/users`, `/folders` routers are
/// CORS-enabled for the opaque app iframe. Default-deny those domains via an explicit
/// allowlist so the token can't reach workspace inventory, counts, exports, or
/// capability-minting routes (job signatures / resume URLs).
fn app_embed_route_denied(domain: ScopeDomain, suffix: &str) -> bool {
match domain {
ScopeDomain::Apps => !app_embed_apps_route_allowed(suffix),
ScopeDomain::Jobs => !app_embed_job_route_allowed(suffix),
ScopeDomain::Users => suffix != "users/whoami",
ScopeDomain::Folders => suffix != "folders/listnames",
_ => false,
}
}
/// App routes a running app uses: its own definition (`apps/get/p/<path>`, further
/// path-scoped by `apps:read:<path>`) and the public app-serving endpoints
/// (`apps_u/*`: public_app, public_resource, get_data, and the path-taking
/// `execute_component` / `download_s3_file`, which re-check `apps:run|read:<path>`
/// in their handlers so they stay confined to this app). Everything else in the
/// domain — workspace app inventory (`exists`, `custom_path_exists`, `list`,
/// `list_paths*`, `secret_of`, history, management) — is denied.
fn app_embed_apps_route_allowed(suffix: &str) -> bool {
// The embed-token mint endpoints live under `apps_u/` but they create
// credentials. A running app never calls them — the trusted embedder session/JWT
// mints the token and hands it to the iframe — so deny them here, otherwise an
// app embed token could renew itself indefinitely past the 12h expiry.
if suffix.starts_with("apps_u/embed_token") {
return false;
}
suffix.starts_with("apps/get/p/") || suffix.starts_with("apps_u/")
}
/// Job routes a running app uses (the by-id poll/cancel surface driven by the
/// frontend JobLoader). Everything else in the jobs domain — enumeration, counts,
/// exports, and the `job_signature`/`resume_urls` capability-minting routes — is
/// denied. By-id reads are further confined to the app's own runs by
/// `require_job_read_access` (the `app_embed` cutoff).
fn app_embed_job_route_allowed(suffix: &str) -> bool {
// `get_root_job_id` is intentionally absent: its handler has no access check at
// all (returns any job's root id by id) and the app never calls it, so denying
// it costs nothing and avoids leaking a foreign job's flow lineage.
const ALLOWED: [&str; 15] = [
"jobs_u/get/",
"jobs_u/getupdate/",
"jobs_u/getupdate_sse/",
"jobs_u/get_logs/",
"jobs_u/get_completed_logs_tail/",
"jobs_u/get_args/",
"jobs_u/get_flow/",
"jobs_u/get_flow_all_logs/",
"jobs_u/get_flow_debug_info/",
"jobs_u/get_log_file/",
"jobs_u/completed/get/",
"jobs_u/completed/get_result/",
"jobs_u/completed/get_result_maybe/",
"jobs_u/completed/get_timing/",
"jobs_u/queue/cancel/",
];
ALLOWED.iter().any(|p| suffix.starts_with(p))
}
/// Resource routes a metadata-only `resources:run` scope (app embed tokens) may
/// GET: pickers (`/list`) and type schemas. Excludes every value-returning route
/// (`get`, `get_value`, `get_value_interpolated`, `list_search`) so resource
/// values — which can hold credentials — are never exposed.
fn resource_metadata_route_allowed(suffix: &str) -> bool {
suffix == "resources/list"
|| suffix.starts_with("resources/list_names/")
|| suffix.starts_with("resources/exists/")
|| suffix.starts_with("resources/type/")
}
fn scope_grants_access(
scope: &ScopeDefinition,
required_domain: ScopeDomain,
@@ -656,6 +769,14 @@ fn scope_grants_access(
let scope_action = ScopeAction::from_str(&scope.action)
.ok_or_else(|| Error::BadRequest(format!("Invalid scope action: {}", scope.action)))?;
// App embed tokens carry `resources:run`: metadata-only resource access via
// default-deny + allowlist (so a new value route is never exposed by accident).
// See `resource_metadata_route_allowed`.
if scope_domain == ScopeDomain::Resources && scope_action == ScopeAction::Run {
return Ok(required_action == ScopeAction::Read
&& route_path.is_some_and(resource_metadata_route_allowed));
}
if !scope_action.includes(&required_action)
&& !(scope_domain == ScopeDomain::Jobs
&& required_action == ScopeAction::Read
@@ -699,6 +820,23 @@ pub fn check_read_only_for_route(route_path: &str, http_method: &str) -> Result<
}
}
/// The minimal scope string that grants access to exactly `{method} {path}`, as
/// `check_route_access` would require it. Used to mint a least-privilege JWT for
/// a single proxied request (the MCP endpoint proxy), so the minted token can do
/// only that one operation rather than acting as a blank check.
///
/// `path` is the request path (e.g. `/api/w/{workspace}/variables/get/...`).
/// Returns `None` if the route's domain can't be determined — the caller should
/// then fail closed.
pub fn scope_for_route(method: &str, path: &str) -> Option<String> {
let action = map_http_method_to_action(method, path);
let (domain, kind, _suffix) = extract_domain_from_route(path).ok()?;
Some(match (domain, action, kind) {
(ScopeDomain::Jobs, ScopeAction::Run, Some(kind)) => format!("jobs:run:{}", kind),
(domain, action, _) => format!("{}:{}", domain.as_str(), action.as_str()),
})
}
/// Helper function to check if scopes allow access to a route
pub fn check_scopes_for_route(
token_scopes: Option<&[String]>,
@@ -789,6 +927,12 @@ mod tests {
assert_eq!(domain, ScopeDomain::FlowConversations);
assert_eq!(kind, None);
assert_eq!(route_suffix, Some("flow_conversations/list".to_string()));
let (domain, kind, route_suffix) =
extract_domain_from_route("/api/w/test_workspace/ai_skills/list").unwrap();
assert_eq!(domain, ScopeDomain::AiSkills);
assert_eq!(kind, None);
assert_eq!(route_suffix, Some("ai_skills/list".to_string()));
}
#[test]
@@ -845,6 +989,10 @@ mod tests {
ScopeDomain::from_str("flow_conversations"),
Some(ScopeDomain::FlowConversations)
);
assert_eq!(
ScopeDomain::from_str("ai_skills"),
Some(ScopeDomain::AiSkills)
);
// Test canonical string conversion
assert_eq!(ScopeDomain::Acls.as_str(), "acls");
@@ -854,6 +1002,41 @@ mod tests {
ScopeDomain::FlowConversations.as_str(),
"flow_conversations"
);
assert_eq!(ScopeDomain::AiSkills.as_str(), "ai_skills");
}
#[test]
fn test_ai_skills_scope_access() {
let read_scopes = vec!["ai_skills:read".to_string()];
assert!(
check_route_access(&read_scopes, "/api/w/test_workspace/ai_skills/list", "GET").is_ok()
);
assert!(check_route_access(
&read_scopes,
"/api/w/test_workspace/ai_skills/get/foo",
"GET"
)
.is_ok());
assert!(check_route_access(
&read_scopes,
"/api/w/test_workspace/ai_skills/upload",
"POST"
)
.is_err());
let write_scopes = vec!["ai_skills:write".to_string()];
assert!(check_route_access(
&write_scopes,
"/api/w/test_workspace/ai_skills/upload",
"POST"
)
.is_ok());
assert!(check_route_access(
&write_scopes,
"/api/w/test_workspace/ai_skills/delete/foo",
"DELETE"
)
.is_ok());
}
#[test]
@@ -1083,4 +1266,38 @@ mod tests {
let scopes = vec!["jobs:read".to_string(), "mcp:all".to_string()];
assert!(check_route_access(&scopes, "/api/w/test_workspace/mcp/something", "GET").is_ok());
}
#[test]
fn test_scope_for_route() {
// The minted scope must be exactly what check_route_access requires for
// the same route, so a JWT carrying it passes for that one route only.
assert_eq!(
scope_for_route("GET", "/api/w/ws/variables/get/u/x/y").as_deref(),
Some("variables:read")
);
assert_eq!(
scope_for_route("POST", "/api/w/ws/variables/create").as_deref(),
Some("variables:write")
);
assert_eq!(
scope_for_route("DELETE", "/api/w/ws/resources/delete/u/x/y").as_deref(),
Some("resources:write")
);
// jobs run paths carry the runnable kind.
assert_eq!(
scope_for_route("POST", "/api/w/ws/jobs/run/p/u/x/y").as_deref(),
Some("jobs:run:scripts")
);
assert_eq!(
scope_for_route("POST", "/api/w/ws/jobs/run/f/u/x/y").as_deref(),
Some("jobs:run:flows")
);
// The minted scope actually satisfies the route check it targets.
let s = scope_for_route("POST", "/api/w/ws/variables/create").unwrap();
assert!(check_route_access(&[s], "/api/w/ws/variables/create", "POST").is_ok());
// Unknown route -> None so the caller fails closed.
assert!(scope_for_route("GET", "/healthz").is_none());
}
}
+4 -22
View File
@@ -1284,28 +1284,10 @@ async fn create_script_internal<'c>(
if let Err(e) = windmill_parser::sql_materialize::classify_wrap(&ns.content) {
return Err(Error::BadRequest(e.message()));
}
// Managed materialize strips line comments when it wraps the SELECT,
// so a `-- $name (TYPE)` declaration is lost while its `$name`
// reference survives in the embedded SELECT — it would run unbound.
// Managed materialize takes no SQL args (the partition is supplied by
// the engine, not bound). Reject declared args with a clear error.
if let Ok(sig) = windmill_parser_sql::parse_duckdb_sig(&ns.content) {
if !sig.args.is_empty() {
let names = sig
.args
.iter()
.map(|a| format!("${}", a.name))
.collect::<Vec<_>>()
.join(", ");
return Err(Error::BadRequest(format!(
"managed `// materialize` cannot take SQL arguments ({names}): wrapping your \
SELECT drops the `-- $arg` declarations, so they would run unbound. The \
partition is supplied by the engine — reference its value with the \
`{{partition}}` token, or use `// materialize manual` to write the DDL (and \
bind args) yourself."
)));
}
}
// SQL args are supported: managed materialize strips line comments
// (including `-- $name (type)` declarations) when it wraps the SELECT,
// but the executor parses the signature from the un-wrapped script, so
// `$name` references in the SELECT stay bound at run time.
}
// `key=` (merge) and `append` are mutually exclusive reconciliation
// strategies; append (INSERT-only) wins. Surface the conflict rather
@@ -395,13 +395,17 @@ async fn delete_expired_jobs_batch(
.fetch_all(&mut *tx)
.await?;
// Active-root exclusion via NOT IN (hashed SubPlan) instead of `!= ALL($3)`;
// see backend/src/monitor.rs::delete_expired_jobs_batch for the rationale.
let deleted_jobs: Vec<Uuid> = sqlx::query_scalar!(
"DELETE FROM v2_job_completed
WHERE id IN (
SELECT jc.id FROM v2_job_completed jc
LEFT JOIN v2_job j ON j.id = jc.id
WHERE jc.completed_at <= now() - ($1::bigint::text || ' s')::interval
AND COALESCE(j.root_job, j.flow_innermost_root_job, jc.id) != ALL($3)
AND COALESCE(j.root_job, j.flow_innermost_root_job, jc.id) NOT IN (
SELECT u FROM unnest($3::uuid[]) AS u WHERE u IS NOT NULL
)
ORDER BY jc.completed_at ASC
LIMIT $2
FOR UPDATE OF jc SKIP LOCKED
+788 -4
View File
@@ -1,7 +1,7 @@
{
"openapi": "3.0.3",
"info": {
"version": "1.728.0",
"version": "1.734.0",
"title": "Windmill API",
"contact": {
"name": "Windmill Team",
@@ -12196,6 +12196,14 @@
"parameters": [
{
"$ref": "#/components/parameters/WorkspaceId"
},
{
"name": "all_users",
"in": "query",
"description": "List every draft in the workspace (all users), not just the current user's own + legacy rows. Other users' rows come back with `mine=false` (view-only).",
"schema": {
"type": "boolean"
}
}
],
"responses": {
@@ -12233,6 +12241,27 @@
"created_at": {
"type": "string",
"format": "date-time"
},
"can_write": {
"type": "boolean",
"description": "Whether the current user may deploy/discard this draft (same check the deploy/discard endpoints enforce)."
},
"mine": {
"type": "boolean",
"description": "The row belongs to the current user (own draft or the legacy no-owner row) and is therefore actionable. Always true in the default listing; with `all_users=true`, other users' rows are false (view-only)."
},
"draft_users": {
"description": "Draft authors at this (path, kind) — the legacy NULL-email row surfaced as a null username.\nPopulated only for the shared full-page-editor kinds (script/flow/app/raw_app); omitted for\ndrawer kinds, which keep their drafts private. Feeds the Draft badge's owner-avatar circles.\n",
"type": "array",
"items": {
"type": "object",
"properties": {
"username": {
"type": "string",
"nullable": true
}
}
}
}
},
"required": [
@@ -12240,7 +12269,9 @@
"path",
"draft_only",
"legacy_draft",
"created_at"
"created_at",
"can_write",
"mine"
]
}
}
@@ -12310,6 +12341,55 @@
}
}
},
"/w/{workspace}/drafts/get_own/{kind}/{path}": {
"get": {
"summary": "fetch the current user's own draft content at a path (any kind)",
"operationId": "getOwnDraft",
"tags": [
"draft"
],
"parameters": [
{
"$ref": "#/components/parameters/WorkspaceId"
},
{
"name": "kind",
"in": "path",
"required": true,
"schema": {
"$ref": "#/components/schemas/UserDraftItemKind"
}
},
{
"$ref": "#/components/parameters/ScriptPath"
}
],
"responses": {
"200": {
"description": "the user's draft content, or null when none exists",
"content": {
"application/json": {
"schema": {
"nullable": true,
"type": "object",
"properties": {
"value": {},
"created_at": {
"type": "string",
"format": "date-time"
}
},
"required": [
"value",
"created_at"
]
}
}
}
}
}
}
},
"/w/{workspace}/drafts/update/{kind}/{path}": {
"post": {
"summary": "upsert (or clear) the current user's draft at a path",
@@ -12356,6 +12436,11 @@
"legacy": {
"type": "boolean",
"description": "Delete-only. Target the legacy workspace-level row (email NULL) instead of the current user's row. Used to discard a legacy draft from the review page."
},
"created_at": {
"type": "string",
"format": "date-time",
"description": "Upsert-only override for the stored creation timestamp. Normal saves omit it (stamped server-side); the localStorage→DB migration passes the draft's original write time so migrated drafts keep their age."
}
}
}
@@ -12393,6 +12478,67 @@
}
}
},
"/w/{workspace}/drafts/migrate_legacy/{kind}/{path}": {
"post": {
"summary": "resolve a legacy (workspace-level) draft (admin only)",
"description": "Delete a legacy draft (email NULL) or assign it to the authed admin as a per-user draft. Workspace admins / superadmins only.",
"operationId": "migrateLegacyDraft",
"tags": [
"draft"
],
"parameters": [
{
"$ref": "#/components/parameters/WorkspaceId"
},
{
"name": "kind",
"in": "path",
"required": true,
"schema": {
"$ref": "#/components/schemas/UserDraftItemKind"
}
},
{
"$ref": "#/components/parameters/ScriptPath"
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": [
"delete",
"assign_to_self"
],
"description": "delete the legacy draft, or take ownership of it."
}
},
"required": [
"action"
]
}
}
}
},
"responses": {
"200": {
"description": "migration result",
"content": {
"text/plain": {
"schema": {
"type": "string"
}
}
}
}
}
}
},
"/w/{workspace}/scripts/create": {
"post": {
"summary": "create script",
@@ -16107,6 +16253,202 @@
}
}
},
"/w/{workspace}/ai_skills/list": {
"get": {
"summary": "list the workspace AI chat skills (name + description only)",
"operationId": "listAiSkills",
"tags": [
"workspace"
],
"parameters": [
{
"$ref": "#/components/parameters/WorkspaceId"
}
],
"responses": {
"200": {
"description": "skill listing",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"type": "object",
"required": [
"name",
"description"
],
"properties": {
"name": {
"type": "string"
},
"description": {
"type": "string"
}
}
}
}
}
}
}
}
}
},
"/w/{workspace}/ai_skills/get/{name}": {
"get": {
"summary": "get a workspace AI chat skill including its instructions",
"operationId": "getAiSkill",
"tags": [
"workspace"
],
"parameters": [
{
"$ref": "#/components/parameters/WorkspaceId"
},
{
"name": "name",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "skill",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"name",
"description",
"instructions"
],
"properties": {
"name": {
"type": "string"
},
"description": {
"type": "string"
},
"instructions": {
"type": "string"
}
}
}
}
}
}
}
}
},
"/w/{workspace}/ai_skills/upload": {
"post": {
"summary": "upsert workspace AI chat skills (admin only)",
"operationId": "uploadAiSkills",
"tags": [
"workspace"
],
"parameters": [
{
"$ref": "#/components/parameters/WorkspaceId"
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"skills"
],
"properties": {
"skills": {
"type": "array",
"maxItems": 50,
"items": {
"type": "object",
"required": [
"name",
"description",
"instructions"
],
"properties": {
"name": {
"type": "string",
"minLength": 1,
"maxLength": 64,
"pattern": "^[a-z0-9-]+$"
},
"description": {
"type": "string",
"minLength": 1,
"maxLength": 1024
},
"instructions": {
"type": "string",
"minLength": 1,
"maxLength": 65536
}
}
}
}
}
}
}
}
},
"responses": {
"200": {
"description": "uploaded",
"content": {
"text/plain": {
"schema": {
"type": "string"
}
}
}
}
}
}
},
"/w/{workspace}/ai_skills/delete/{name}": {
"delete": {
"summary": "delete a workspace AI chat skill (admin only)",
"operationId": "deleteAiSkill",
"tags": [
"workspace"
],
"parameters": [
{
"$ref": "#/components/parameters/WorkspaceId"
},
{
"name": "name",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "deleted",
"content": {
"text/plain": {
"schema": {
"type": "string"
}
}
}
}
}
}
},
"/w/{workspace}/apps/get_data/v/{secretWithExtension}": {
"get": {
"summary": "get raw app data by",
@@ -20409,6 +20751,192 @@
}
}
},
"/w/{workspace}/jobs_u/dispatch_events/{id}": {
"get": {
"summary": "list asset-trigger dispatch events for a producer job",
"description": "Returns the chronological log of decisions the asset-trigger dispatcher made after this producer job completed. Each row is one (subscriber, asset write) decision: `dispatched` (with `child_job_id`), `join_pending` (with `received_inputs` / `required_inputs` / `partition`), or `skipped` (with `reason`). Rows are reaped automatically when the producer's `v2_job` row is deleted by the retention sweep.\n",
"operationId": "listDispatchEvents",
"tags": [
"job"
],
"parameters": [
{
"$ref": "#/components/parameters/WorkspaceId"
},
{
"$ref": "#/components/parameters/JobId"
}
],
"responses": {
"200": {
"description": "dispatch events for this producer job",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"type": "object",
"properties": {
"subscriber_path": {
"type": "string"
},
"asset_kind": {
"type": "string",
"enum": [
"s3object",
"resource",
"variable",
"ducklake",
"datatable",
"volume"
]
},
"asset_path": {
"type": "string"
},
"outcome": {
"type": "string",
"enum": [
"dispatched",
"join_pending",
"skipped"
]
},
"child_job_id": {
"type": "string",
"format": "uuid"
},
"partition": {
"type": "string"
},
"received_inputs": {
"type": "integer"
},
"required_inputs": {
"type": "integer"
},
"debounce_s": {
"type": "integer"
},
"reason": {
"type": "string"
},
"created_at": {
"type": "string",
"format": "date-time"
}
},
"required": [
"subscriber_path",
"asset_kind",
"asset_path",
"outcome",
"created_at"
]
}
}
}
}
}
}
}
},
"/w/{workspace}/jobs/asset_dispatch_edges": {
"get": {
"summary": "list asset-cascade producer→child job edges for a folder",
"description": "Returns the `dispatched` asset-trigger edges (producer job → child job) whose subscriber lives under `path_start`. Lets a pipeline view reconstruct the cascade tree of a folder by job id and group connected runs. Visibility follows the producer job's RLS.\n",
"operationId": "listAssetDispatchEdges",
"tags": [
"job"
],
"parameters": [
{
"$ref": "#/components/parameters/WorkspaceId"
},
{
"name": "path_start",
"in": "query",
"required": true,
"description": "Folder path prefix the children live under, e.g. `f/orders/`.",
"schema": {
"type": "string"
}
},
{
"name": "created_after",
"in": "query",
"required": false,
"description": "Only edges dispatched at/after this instant.",
"schema": {
"type": "string",
"format": "date-time"
}
}
],
"responses": {
"200": {
"description": "asset-cascade edges for the folder",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"type": "object",
"properties": {
"producer_job_id": {
"type": "string",
"format": "uuid"
},
"child_job_id": {
"type": "string",
"format": "uuid",
"description": "Set for `dispatched`; absent for `join_pending` inputs."
},
"subscriber_path": {
"type": "string"
},
"outcome": {
"type": "string",
"enum": [
"dispatched",
"join_pending"
]
},
"asset_kind": {
"type": "string",
"enum": [
"s3object",
"resource",
"variable",
"ducklake",
"datatable",
"volume"
]
},
"asset_path": {
"type": "string"
},
"created_at": {
"type": "string",
"format": "date-time"
}
},
"required": [
"producer_job_id",
"subscriber_path",
"outcome",
"asset_kind",
"asset_path",
"created_at"
]
}
}
}
}
}
}
}
},
"/w/{workspace}/jobs/completed/delete/{id}": {
"post": {
"summary": "delete completed job (erase content but keep run id)",
@@ -21105,6 +21633,10 @@
}
}
}
},
"view_token": {
"type": "string",
"description": "Share-read-link token for the flow. An authenticated workspace member can append it as a `view_token` query param on the run page to read a flow they don't otherwise have access to."
}
}
}
@@ -21525,6 +22057,10 @@
"approver"
]
}
},
"view_token": {
"type": "string",
"description": "Share-read-link token for the parent flow. An authenticated workspace member can append it as a `view_token` query param on the run page to read a flow they don't otherwise have access to."
}
},
"required": [
@@ -32745,6 +33281,241 @@
}
}
},
"/w/{workspace}/assets/graph": {
"get": {
"summary": "Get the workspace-wide asset <-> runnable graph",
"operationId": "getAssetsGraph",
"tags": [
"asset"
],
"parameters": [
{
"$ref": "#/components/parameters/WorkspaceId"
},
{
"name": "asset_kinds",
"in": "query",
"description": "Filter by asset kinds (comma-separated list)",
"schema": {
"type": "string"
}
},
{
"name": "folder",
"in": "query",
"description": "Scope the graph to runnables in a single folder",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "asset graph nodes, lineage edges and trigger edges",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"assets",
"runnables",
"edges",
"triggers"
],
"properties": {
"assets": {
"type": "array",
"items": {
"type": "object",
"required": [
"kind",
"path"
],
"properties": {
"kind": {
"$ref": "#/components/schemas/AssetKind"
},
"path": {
"type": "string"
}
}
}
},
"runnables": {
"type": "array",
"items": {
"type": "object",
"required": [
"path",
"usage_kind"
],
"properties": {
"path": {
"type": "string"
},
"usage_kind": {
"$ref": "#/components/schemas/AssetUsageKind"
},
"in_pipeline": {
"type": "boolean",
"description": "True iff the script is a pipeline member (deployed with `// pipeline`). Omitted when false."
}
}
}
},
"edges": {
"type": "array",
"items": {
"type": "object",
"required": [
"runnable_path",
"runnable_kind",
"asset_kind",
"asset_path"
],
"properties": {
"runnable_path": {
"type": "string"
},
"runnable_kind": {
"$ref": "#/components/schemas/AssetUsageKind"
},
"asset_kind": {
"$ref": "#/components/schemas/AssetKind"
},
"asset_path": {
"type": "string"
},
"access_type": {
"$ref": "#/components/schemas/AssetUsageAccessType"
}
}
}
},
"triggers": {
"type": "array",
"items": {
"oneOf": [
{
"type": "object",
"description": "Asset trigger edge (`// on <asset>`)",
"required": [
"trigger_kind",
"asset_kind",
"asset_path",
"runnable_kind",
"runnable_path"
],
"properties": {
"trigger_kind": {
"type": "string",
"enum": [
"asset"
]
},
"asset_kind": {
"$ref": "#/components/schemas/AssetKind"
},
"asset_path": {
"type": "string"
},
"runnable_kind": {
"$ref": "#/components/schemas/AssetUsageKind"
},
"runnable_path": {
"type": "string"
}
}
},
{
"type": "object",
"description": "Native trigger edge (schedule, email, kafka, ...). `path` is the trigger row's path.",
"required": [
"trigger_kind",
"path",
"runnable_kind",
"runnable_path"
],
"properties": {
"trigger_kind": {
"type": "string",
"enum": [
"schedule",
"email",
"kafka",
"mqtt",
"nats",
"postgres",
"sqs",
"gcp"
]
},
"path": {
"type": "string"
},
"runnable_kind": {
"$ref": "#/components/schemas/AssetUsageKind"
},
"runnable_path": {
"type": "string"
}
}
}
]
}
}
}
}
}
}
}
}
}
},
"/w/{workspace}/assets/pipelines": {
"get": {
"summary": "List folders that contain at least one pipeline-member script",
"operationId": "listPipelineFolders",
"tags": [
"asset"
],
"parameters": [
{
"$ref": "#/components/parameters/WorkspaceId"
}
],
"responses": {
"200": {
"description": "folders containing pipeline scripts, with their script counts",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"type": "object",
"required": [
"folder",
"script_count"
],
"properties": {
"folder": {
"type": "string",
"description": "The folder name (without the `f/` prefix)"
},
"script_count": {
"type": "integer",
"format": "int64",
"description": "Number of pipeline-member scripts in the folder"
}
}
}
}
}
}
}
}
}
},
"/w/{workspace}/volumes/list": {
"get": {
"summary": "List all volumes in the workspace",
@@ -33694,7 +34465,8 @@
"trigger_cli",
"trigger_nextcloud",
"trigger_google",
"trigger_github"
"trigger_github",
"data_pipeline"
]
},
"OpenFlow": {
@@ -36316,6 +37088,10 @@
"parent_hash": {
"type": "string"
},
"auto_parent": {
"type": "boolean",
"description": "When true, the backend resolves the parent to the current deployed head for this path within the transaction (ignoring parent_hash), instead of failing with a \"lineage must be linear\" error when the supplied parent_hash is stale."
},
"summary": {
"type": "string"
},
@@ -37381,6 +38157,12 @@
"type": "string"
}
},
"folders_read": {
"type": "array",
"items": {
"type": "string"
}
},
"folders_owners": {
"type": "array",
"items": {
@@ -37408,6 +38190,7 @@
"operator",
"disabled",
"folders",
"folders_read",
"folders_owners"
]
},
@@ -39311,7 +40094,8 @@
"gcp",
"azure",
"google",
"github"
"github",
"asset"
]
},
"TriggerMode": {
+631 -4
View File
@@ -1,6 +1,6 @@
openapi: 3.0.3
info:
version: 1.728.0
version: 1.734.0
title: Windmill API
contact:
name: Windmill Team
@@ -749,6 +749,10 @@ paths:
type: array
items:
type: string
folders_read:
type: array
items:
type: string
folders_owners:
type: array
items:
@@ -788,6 +792,7 @@ paths:
- operator
- disabled
- folders
- folders_read
- folders_owners
/w/{workspace}/users/update/{username}:
post:
@@ -8995,9 +9000,10 @@ paths:
cc_token_url:
type: string
description: >-
Bring-your-own token endpoint override. Only honored together
with cc_client_id/cc_client_secret and mutually exclusive with
cc_instance; rejected on the shared-instance path.
Bring-your-own token endpoint override. Only honored
together with cc_client_id/cc_client_secret and mutually
exclusive with cc_instance; rejected on the shared-instance
path.
responses:
'200':
description: OAuth token response
@@ -12721,6 +12727,14 @@ paths:
in: path
required: true
schema: *ref_4
- name: all_users
in: query
description: >-
List every draft in the workspace (all users), not just the current
user's own + legacy rows. Other users' rows come back with
`mine=false` (view-only).
schema:
type: boolean
responses:
'200':
description: the user's drafts
@@ -12764,6 +12778,7 @@ paths:
- trigger_nextcloud
- trigger_google
- trigger_github
- data_pipeline
path:
type: string
summary:
@@ -12792,12 +12807,43 @@ paths:
created_at:
type: string
format: date-time
can_write:
type: boolean
description: >-
Whether the current user may deploy/discard this draft
(same check the deploy/discard endpoints enforce).
mine:
type: boolean
description: >-
The row belongs to the current user (own draft or the
legacy no-owner row) and is therefore actionable. Always
true in the default listing; with `all_users=true`,
other users' rows are false (view-only).
draft_users:
description: >
Draft authors at this (path, kind) — the legacy
NULL-email row surfaced as a null username.
Populated only for the shared full-page-editor kinds
(script/flow/app/raw_app); omitted for
drawer kinds, which keep their drafts private. Feeds the
Draft badge's owner-avatar circles.
type: array
items:
type: object
properties:
username:
type: string
nullable: true
required:
- kind
- path
- draft_only
- legacy_draft
- created_at
- can_write
- mine
/w/{workspace}/drafts/get/{kind}/{path}:
get:
summary: >-
@@ -12851,6 +12897,48 @@ paths:
- created_at
'404':
description: no draft for that owner at that path
/w/{workspace}/drafts/get_own/{kind}/{path}:
get:
summary: fetch the current user's own draft content at a path (any kind)
operationId: getOwnDraft
tags:
- draft
parameters:
- name: workspace
in: path
required: true
schema: *ref_4
- name: kind
in: path
required: true
schema:
type: string
description: >
Closed set of item kinds a user can autosave as a draft. Mirrors
the
Postgres `DRAFT_KIND` enum and the backend `UserDraftItemKind`.
enum: *ref_100
- name: path
in: path
required: true
schema: *ref_97
responses:
'200':
description: the user's draft content, or null when none exists
content:
application/json:
schema:
nullable: true
type: object
properties:
value: {}
created_at:
type: string
format: date-time
required:
- value
- created_at
/w/{workspace}/drafts/update/{kind}/{path}:
post:
summary: upsert (or clear) the current user's draft at a path
@@ -12904,6 +12992,14 @@ paths:
Delete-only. Target the legacy workspace-level row (email
NULL) instead of the current user's row. Used to discard a
legacy draft from the review page.
created_at:
type: string
format: date-time
description: >-
Upsert-only override for the stored creation timestamp.
Normal saves omit it (stamped server-side); the
localStorage→DB migration passes the draft's original write
time so migrated drafts keep their age.
responses:
'200':
description: save result
@@ -12923,6 +13019,57 @@ paths:
required:
- status
- current_timestamp
/w/{workspace}/drafts/migrate_legacy/{kind}/{path}:
post:
summary: resolve a legacy (workspace-level) draft (admin only)
description: >-
Delete a legacy draft (email NULL) or assign it to the authed admin as a
per-user draft. Workspace admins / superadmins only.
operationId: migrateLegacyDraft
tags:
- draft
parameters:
- name: workspace
in: path
required: true
schema: *ref_4
- name: kind
in: path
required: true
schema:
type: string
description: >
Closed set of item kinds a user can autosave as a draft. Mirrors
the
Postgres `DRAFT_KIND` enum and the backend `UserDraftItemKind`.
enum: *ref_100
- name: path
in: path
required: true
schema: *ref_97
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
action:
type: string
enum:
- delete
- assign_to_self
description: delete the legacy draft, or take ownership of it.
required:
- action
responses:
'200':
description: migration result
content:
text/plain:
schema:
type: string
/w/{workspace}/scripts/create:
post:
summary: create script
@@ -12971,6 +13118,13 @@ paths:
type: string
parent_hash:
type: string
auto_parent:
type: boolean
description: >-
When true, the backend resolves the parent to the current
deployed head for this path within the transaction (ignoring
parent_hash), instead of failing with a "lineage must be
linear" error when the supplied parent_hash is stale.
summary:
type: string
description:
@@ -16613,6 +16767,141 @@ paths:
text/plain:
schema:
type: string
/w/{workspace}/ai_skills/list:
get:
summary: list the workspace AI chat skills (name + description only)
operationId: listAiSkills
tags:
- workspace
parameters:
- name: workspace
in: path
required: true
schema: *ref_4
responses:
'200':
description: skill listing
content:
application/json:
schema:
type: array
items:
type: object
required:
- name
- description
properties:
name:
type: string
description:
type: string
/w/{workspace}/ai_skills/get/{name}:
get:
summary: get a workspace AI chat skill including its instructions
operationId: getAiSkill
tags:
- workspace
parameters:
- name: workspace
in: path
required: true
schema: *ref_4
- name: name
in: path
required: true
schema:
type: string
responses:
'200':
description: skill
content:
application/json:
schema:
type: object
required:
- name
- description
- instructions
properties:
name:
type: string
description:
type: string
instructions:
type: string
/w/{workspace}/ai_skills/upload:
post:
summary: upsert workspace AI chat skills (admin only)
operationId: uploadAiSkills
tags:
- workspace
parameters:
- name: workspace
in: path
required: true
schema: *ref_4
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- skills
properties:
skills:
type: array
maxItems: 50
items:
type: object
required:
- name
- description
- instructions
properties:
name:
type: string
minLength: 1
maxLength: 64
pattern: ^[a-z0-9-]+$
description:
type: string
minLength: 1
maxLength: 1024
instructions:
type: string
minLength: 1
maxLength: 65536
responses:
'200':
description: uploaded
content:
text/plain:
schema:
type: string
/w/{workspace}/ai_skills/delete/{name}:
delete:
summary: delete a workspace AI chat skill (admin only)
operationId: deleteAiSkill
tags:
- workspace
parameters:
- name: workspace
in: path
required: true
schema: *ref_4
- name: name
in: path
required: true
schema:
type: string
responses:
'200':
description: deleted
content:
text/plain:
schema:
type: string
/w/{workspace}/apps/get_data/v/{secretWithExtension}:
get:
summary: get raw app data by
@@ -19986,6 +20275,7 @@ paths:
- azure
- google
- github
- asset
- name: trigger_path
description: The path of the trigger (can contain forward slashes)
in: path
@@ -21551,6 +21841,154 @@ paths:
type: integer
required:
- created_at
/w/{workspace}/jobs_u/dispatch_events/{id}:
get:
summary: list asset-trigger dispatch events for a producer job
description: >
Returns the chronological log of decisions the asset-trigger dispatcher
made after this producer job completed. Each row is one (subscriber,
asset write) decision: `dispatched` (with `child_job_id`),
`join_pending` (with `received_inputs` / `required_inputs` /
`partition`), or `skipped` (with `reason`). Rows are reaped
automatically when the producer's `v2_job` row is deleted by the
retention sweep.
operationId: listDispatchEvents
tags:
- job
parameters:
- name: workspace
in: path
required: true
schema: *ref_4
- name: id
in: path
required: true
schema: *ref_176
responses:
'200':
description: dispatch events for this producer job
content:
application/json:
schema:
type: array
items:
type: object
properties:
subscriber_path:
type: string
asset_kind:
type: string
enum:
- s3object
- resource
- variable
- ducklake
- datatable
- volume
asset_path:
type: string
outcome:
type: string
enum:
- dispatched
- join_pending
- skipped
child_job_id:
type: string
format: uuid
partition:
type: string
received_inputs:
type: integer
required_inputs:
type: integer
debounce_s:
type: integer
reason:
type: string
created_at:
type: string
format: date-time
required:
- subscriber_path
- asset_kind
- asset_path
- outcome
- created_at
/w/{workspace}/jobs/asset_dispatch_edges:
get:
summary: list asset-cascade producer→child job edges for a folder
description: >
Returns the `dispatched` asset-trigger edges (producer job → child job)
whose subscriber lives under `path_start`. Lets a pipeline view
reconstruct the cascade tree of a folder by job id and group connected
runs. Visibility follows the producer job's RLS.
operationId: listAssetDispatchEdges
tags:
- job
parameters:
- name: workspace
in: path
required: true
schema: *ref_4
- name: path_start
in: query
required: true
description: Folder path prefix the children live under, e.g. `f/orders/`.
schema:
type: string
- name: created_after
in: query
required: false
description: Only edges dispatched at/after this instant.
schema:
type: string
format: date-time
responses:
'200':
description: asset-cascade edges for the folder
content:
application/json:
schema:
type: array
items:
type: object
properties:
producer_job_id:
type: string
format: uuid
child_job_id:
type: string
format: uuid
description: Set for `dispatched`; absent for `join_pending` inputs.
subscriber_path:
type: string
outcome:
type: string
enum:
- dispatched
- join_pending
asset_kind:
type: string
enum:
- s3object
- resource
- variable
- ducklake
- datatable
- volume
asset_path:
type: string
created_at:
type: string
format: date-time
required:
- producer_job_id
- subscriber_path
- outcome
- asset_kind
- asset_path
- created_at
/w/{workspace}/jobs/completed/delete/{id}:
post:
summary: delete completed job (erase content but keep run id)
@@ -22053,6 +22491,13 @@ paths:
type: integer
approver:
type: string
view_token:
type: string
description: >-
Share-read-link token for the flow. An authenticated
workspace member can append it as a `view_token` query
param on the run page to read a flow they don't otherwise
have access to.
/w/{workspace}/jobs_u/resume/{id}/{resume_id}/{signature}:
get:
summary: resume a job for a suspended flow
@@ -22353,6 +22798,13 @@ paths:
required:
- resume_id
- approver
view_token:
type: string
description: >-
Share-read-link token for the parent flow. An
authenticated workspace member can append it as a
`view_token` query param on the run page to read a flow
they don't otherwise have access to.
required:
- job
- approvers
@@ -34855,6 +35307,181 @@ paths:
path:
type: string
description: The asset path
/w/{workspace}/assets/graph:
get:
summary: Get the workspace-wide asset <-> runnable graph
operationId: getAssetsGraph
tags:
- asset
parameters:
- name: workspace
in: path
required: true
schema: *ref_4
- name: asset_kinds
in: query
description: Filter by asset kinds (comma-separated list)
schema:
type: string
- name: folder
in: query
description: Scope the graph to runnables in a single folder
schema:
type: string
responses:
'200':
description: asset graph nodes, lineage edges and trigger edges
content:
application/json:
schema:
type: object
required:
- assets
- runnables
- edges
- triggers
properties:
assets:
type: array
items:
type: object
required:
- kind
- path
properties:
kind:
type: string
enum: *ref_306
path:
type: string
runnables:
type: array
items:
type: object
required:
- path
- usage_kind
properties:
path:
type: string
usage_kind:
type: string
enum: *ref_308
in_pipeline:
type: boolean
description: >-
True iff the script is a pipeline member (deployed
with `// pipeline`). Omitted when false.
edges:
type: array
items:
type: object
required:
- runnable_path
- runnable_kind
- asset_kind
- asset_path
properties:
runnable_path:
type: string
runnable_kind:
type: string
enum: *ref_308
asset_kind:
type: string
enum: *ref_306
asset_path:
type: string
access_type:
type: string
enum: *ref_307
nullable: true
triggers:
type: array
items:
oneOf:
- type: object
description: Asset trigger edge (`// on <asset>`)
required:
- trigger_kind
- asset_kind
- asset_path
- runnable_kind
- runnable_path
properties:
trigger_kind:
type: string
enum:
- asset
asset_kind:
type: string
enum: *ref_306
asset_path:
type: string
runnable_kind:
type: string
enum: *ref_308
runnable_path:
type: string
- type: object
description: >-
Native trigger edge (schedule, email, kafka, ...).
`path` is the trigger row's path.
required:
- trigger_kind
- path
- runnable_kind
- runnable_path
properties:
trigger_kind:
type: string
enum:
- schedule
- email
- kafka
- mqtt
- nats
- postgres
- sqs
- gcp
path:
type: string
runnable_kind:
type: string
enum: *ref_308
runnable_path:
type: string
/w/{workspace}/assets/pipelines:
get:
summary: List folders that contain at least one pipeline-member script
operationId: listPipelineFolders
tags:
- asset
parameters:
- name: workspace
in: path
required: true
schema: *ref_4
responses:
'200':
description: folders containing pipeline scripts, with their script counts
content:
application/json:
schema:
type: array
items:
type: object
required:
- folder
- script_count
properties:
folder:
type: string
description: The folder name (without the `f/` prefix)
script_count:
type: integer
format: int64
description: Number of pipeline-member scripts in the folder
/w/{workspace}/volumes/list:
get:
summary: List all volumes in the workspace
+229 -1
View File
@@ -1,7 +1,7 @@
openapi: "3.0.3"
info:
version: 1.735.0
version: 1.737.0
title: Windmill API
contact:
@@ -7501,6 +7501,22 @@ paths:
workspace_id:
type: string
/apps_u/embed_token_by_custom_path/{custom_path}:
get:
summary: get app embed token by custom path
operationId: getAppEmbedTokenByCustomPath
tags:
- app
parameters:
- $ref: "#/components/parameters/CustomPath"
responses:
"200":
description: embed token
content:
application/json:
schema:
$ref: "#/components/schemas/EmbedTokenResponse"
/scripts/hub/get/{path}:
get:
summary: get hub script content by path
@@ -10431,6 +10447,133 @@ paths:
schema:
type: string
/w/{workspace}/ai_skills/list:
get:
summary: list the workspace AI chat skills (name + description only)
operationId: listAiSkills
tags:
- workspace
parameters:
- $ref: "#/components/parameters/WorkspaceId"
responses:
"200":
description: skill listing
content:
application/json:
schema:
type: array
items:
type: object
required:
- name
- description
properties:
name:
type: string
description:
type: string
/w/{workspace}/ai_skills/get/{name}:
get:
summary: get a workspace AI chat skill including its instructions
operationId: getAiSkill
tags:
- workspace
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- name: name
in: path
required: true
schema:
type: string
responses:
"200":
description: skill
content:
application/json:
schema:
type: object
required:
- name
- description
- instructions
properties:
name:
type: string
description:
type: string
instructions:
type: string
/w/{workspace}/ai_skills/upload:
post:
summary: upsert workspace AI chat skills (admin only)
operationId: uploadAiSkills
tags:
- workspace
parameters:
- $ref: "#/components/parameters/WorkspaceId"
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- skills
properties:
skills:
type: array
maxItems: 50
items:
type: object
required:
- name
- description
- instructions
properties:
name:
type: string
minLength: 1
maxLength: 64
pattern: "^[a-z0-9-]+$"
description:
type: string
minLength: 1
maxLength: 1024
instructions:
type: string
minLength: 1
maxLength: 65536
responses:
"200":
description: uploaded
content:
text/plain:
schema:
type: string
/w/{workspace}/ai_skills/delete/{name}:
delete:
summary: delete a workspace AI chat skill (admin only)
operationId: deleteAiSkill
tags:
- workspace
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- name: name
in: path
required: true
schema:
type: string
responses:
"200":
description: deleted
content:
text/plain:
schema:
type: string
/w/{workspace}/apps/get_data/v/{secretWithExtension}:
get:
summary: get raw app data by
@@ -10442,6 +10585,10 @@ paths:
- name: secretWithExtension
in: path
required: true
description: >-
App version secret suffixed with the requested file type extension.
Supported extensions are `.js` (JavaScript bundle), `.css`
(stylesheet), and `.html` (sandboxed wrapper document).
schema:
type: string
responses:
@@ -10451,6 +10598,12 @@ paths:
text/javascript:
schema:
type: string
text/css:
schema:
type: string
text/html:
schema:
type: string
/w/{workspace}/apps/list_search:
get:
@@ -10702,6 +10855,23 @@ paths:
- $ref: "#/components/schemas/AppWithLastVersion"
- $ref: "#/components/schemas/UserDraftOverlay"
/w/{workspace}/apps/embed_token/p/{path}:
get:
summary: get app embed token by path
operationId: getAppEmbedTokenByPath
tags:
- app
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- $ref: "#/components/parameters/ScriptPath"
responses:
"200":
description: embed token
content:
application/json:
schema:
$ref: "#/components/schemas/EmbedTokenResponse"
/w/{workspace}/apps/get/lite/{path}:
get:
summary: get app lite by path
@@ -10819,6 +10989,27 @@ paths:
schema:
$ref: "#/components/schemas/AppWithLastVersion"
/w/{workspace}/apps_u/embed_token/{secret}:
get:
summary: get app embed token by secret
operationId: getAppEmbedTokenBySecret
tags:
- app
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- name: secret
in: path
required: true
schema:
type: string
responses:
"200":
description: embed token
content:
application/json:
schema:
$ref: "#/components/schemas/EmbedTokenResponse"
/w/{workspace}/apps_u/public_resource/{path}:
get:
summary: get public resource
@@ -27706,6 +27897,13 @@ components:
type: string
on_behalf_of_email:
type: string
sandbox:
type: boolean
description: >
Publisher opt-in to app sandbox isolation (alpha). When true the app
is isolated from each viewer's Windmill session. When false/absent
the app runs same-origin with the viewer's full session (the
default, pre-isolation behavior).
ListableApp:
type: object
@@ -27925,6 +28123,36 @@ components:
required:
- version
EmbedTokenResponse:
type: object
properties:
token:
type: string
nullable: true
description: Narrowly-scoped embed token for the iframe. Absent for fully anonymous or raw apps, which load without a scoped token.
expiration:
type: string
format: date-time
nullable: true
description: Expiration of the embed token.
raw_app:
type: boolean
description: Raw apps render single-iframe and skip the opaque-viewer indirection and the embed token entirely.
sandbox:
type: boolean
description: Publisher opted this app into sandbox isolation. When false the viewer runs the app same-origin with its full session.
app_path:
type: string
nullable: true
description: The resolved app path; the embedder uses it to scope the app's backing localStorage per app.
workspace_id:
type: string
nullable: true
description: The resolved workspace; pairs with app_path so apps at the same path in different workspaces don't share a localStorage store.
required:
- raw_app
- sandbox
FlowVersion:
type: object
properties:
+394
View File
@@ -0,0 +1,394 @@
/*
* Author: Ruben Fiszel
* Copyright: Windmill Labs, Inc 2026
* This file and its contents are licensed under the AGPLv3 License.
* Please see the included NOTICE for copyright information and
* LICENSE-AGPL for a copy of the license.
*/
use crate::db::{ApiAuthed, DB};
use std::collections::HashSet;
use axum::{
extract::{Extension, Json, Path},
routing::{delete, get, post},
Router,
};
use serde::{Deserialize, Serialize};
use windmill_audit::audit_oss::audit_log;
use windmill_audit::ActionKind;
use windmill_common::{
db::UserDB,
error::{Error, JsonResult, Result},
utils::require_admin,
};
pub fn workspaced_service() -> Router {
Router::new()
.route("/list", get(list_skills))
.route("/get/{name}", get(get_skill))
.route("/upload", post(upload_skills))
.route("/delete/{name}", delete(delete_skill))
}
/// Cheap listing surfaced in the AI chat system prompt — no `instructions` body.
#[derive(Serialize)]
pub struct SkillListItem {
pub name: String,
pub description: String,
}
/// Full skill, including the SKILL.md body, fetched on demand by `read_skill`.
#[derive(Serialize)]
pub struct Skill {
pub name: String,
pub description: String,
pub instructions: String,
}
#[derive(Deserialize)]
pub struct UploadSkills {
pub skills: Vec<SkillUpload>,
}
#[derive(Deserialize)]
pub struct SkillUpload {
pub name: String,
pub description: String,
pub instructions: String,
}
const MAX_SKILLS_PER_UPLOAD: usize = 50;
// Every stored skill's name + description is advertised in the global AI chat
// system prompt, so bound the total a workspace can accumulate across uploads.
const MAX_SKILLS_PER_WORKSPACE: usize = 100;
// `name` and `description` follow the Claude SKILL.md spec
// (https://platform.claude.com/docs/en/agents-and-tools/agent-skills): both are
// loaded into the AI chat system prompt and `name` is the model-facing skill id,
// so matching the upstream limits keeps skills portable with Claude Code.
const MAX_SKILL_NAME_CHARS: usize = 64;
const MAX_SKILL_DESCRIPTION_CHARS: usize = 1_024;
// Not a spec field — a payload bound on the SKILL.md body, so measured in bytes.
const MAX_SKILL_INSTRUCTIONS_BYTES: usize = 64 * 1024;
fn validate_skill(skill: &SkillUpload) -> Result<()> {
let name = skill.name.trim();
if name.is_empty() || name.chars().count() > MAX_SKILL_NAME_CHARS {
return Err(Error::BadRequest(format!(
"skill name must be between 1 and {MAX_SKILL_NAME_CHARS} characters, got {:?}",
skill.name
)));
}
if !name
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
{
return Err(Error::BadRequest(format!(
"skill name {name:?} must only contain lowercase letters, digits or '-'"
)));
}
if skill.description.trim().is_empty() {
return Err(Error::BadRequest(format!(
"skill {name:?} is missing a description (the SKILL.md frontmatter `description`)"
)));
}
if skill.description.chars().count() > MAX_SKILL_DESCRIPTION_CHARS {
return Err(Error::BadRequest(format!(
"skill {name:?} description must be at most {MAX_SKILL_DESCRIPTION_CHARS} characters"
)));
}
if skill.instructions.trim().is_empty() {
return Err(Error::BadRequest(format!(
"skill {name:?} has an empty SKILL.md body"
)));
}
if skill.instructions.len() > MAX_SKILL_INSTRUCTIONS_BYTES {
return Err(Error::BadRequest(format!(
"skill {name:?} instructions must be at most {MAX_SKILL_INSTRUCTIONS_BYTES} bytes"
)));
}
Ok(())
}
/// Collect the trimmed skill names, rejecting duplicates within a single upload.
/// The insert upserts by name, so a duplicate would silently keep only the last
/// and make the reported/audited count wrong.
fn collect_upload_names(skills: &[SkillUpload]) -> Result<Vec<String>> {
let mut names = Vec::with_capacity(skills.len());
let mut seen = HashSet::with_capacity(skills.len());
for skill in skills {
let name = skill.name.trim().to_string();
if !seen.insert(name.clone()) {
return Err(Error::BadRequest(format!(
"duplicate skill name {name:?} in upload"
)));
}
names.push(name);
}
Ok(names)
}
/// Reject an upload that would push the workspace past `MAX_SKILLS_PER_WORKSPACE`.
/// Uploads upsert, so names already present (`replacing`) don't count as new.
fn check_workspace_skill_capacity(
existing_total: i64,
replacing: i64,
upload_count: usize,
) -> Result<()> {
let new_count = upload_count as i64 - replacing;
if existing_total + new_count > MAX_SKILLS_PER_WORKSPACE as i64 {
return Err(Error::BadRequest(format!(
"workspace cannot store more than {MAX_SKILLS_PER_WORKSPACE} skills"
)));
}
Ok(())
}
async fn list_skills(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Path(w_id): Path<String>,
) -> JsonResult<Vec<SkillListItem>> {
let mut tx = user_db.begin(&authed).await?;
let rows = sqlx::query!(
"SELECT name, description FROM ai_skill WHERE workspace_id = $1 ORDER BY name",
&w_id
)
.fetch_all(&mut *tx)
.await?;
tx.commit().await?;
Ok(Json(
rows.into_iter()
.map(|r| SkillListItem { name: r.name, description: r.description })
.collect(),
))
}
async fn get_skill(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Path((w_id, name)): Path<(String, String)>,
) -> JsonResult<Skill> {
let mut tx = user_db.begin(&authed).await?;
let row = sqlx::query!(
"SELECT name, description, instructions FROM ai_skill WHERE workspace_id = $1 AND name = $2",
&w_id,
&name
)
.fetch_optional(&mut *tx)
.await?;
tx.commit().await?;
row.map(|r| {
Json(Skill { name: r.name, description: r.description, instructions: r.instructions })
})
.ok_or_else(|| Error::NotFound(format!("no skill named {name:?} in workspace {w_id}")))
}
/// Bulk upsert the uploaded skills by name. Existing skills not in the payload
/// are left untouched — removal goes through `delete_skill`.
async fn upload_skills(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Path(w_id): Path<String>,
Json(payload): Json<UploadSkills>,
) -> Result<String> {
require_admin(authed.is_admin, &authed.username)?;
if payload.skills.is_empty() {
return Err(Error::BadRequest("no skills to upload".to_string()));
}
if payload.skills.len() > MAX_SKILLS_PER_UPLOAD {
return Err(Error::BadRequest(format!(
"cannot upload more than {MAX_SKILLS_PER_UPLOAD} skills at a time"
)));
}
for skill in &payload.skills {
validate_skill(skill)?;
}
let names = collect_upload_names(&payload.skills)?;
let mut tx = db.begin().await?;
let counts = sqlx::query!(
r#"SELECT
COUNT(*)::bigint AS "total!",
COUNT(*) FILTER (WHERE name = ANY($2::text[]))::bigint AS "replacing!"
FROM ai_skill
WHERE workspace_id = $1"#,
&w_id,
&names
)
.fetch_one(&mut *tx)
.await?;
check_workspace_skill_capacity(counts.total, counts.replacing, names.len())?;
for (skill, name) in payload.skills.iter().zip(names.iter()) {
sqlx::query!(
r#"INSERT INTO ai_skill (workspace_id, name, description, instructions, edited_at, edited_by)
VALUES ($1, $2, $3, $4, now(), $5)
ON CONFLICT (workspace_id, name) DO UPDATE
SET description = EXCLUDED.description,
instructions = EXCLUDED.instructions,
edited_at = now(),
edited_by = EXCLUDED.edited_by"#,
&w_id,
name,
skill.description,
skill.instructions,
&authed.username,
)
.execute(&mut *tx)
.await?;
}
let audit_resource = names.join(",");
audit_log(
&mut *tx,
&authed,
"ai_skills.upload",
ActionKind::Update,
&w_id,
Some(&audit_resource),
Some([("skill_count", &names.len().to_string()[..])].into()),
)
.await?;
tx.commit().await?;
Ok(format!(
"Uploaded {} skill(s) to workspace {}",
payload.skills.len(),
&w_id
))
}
async fn delete_skill(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Path((w_id, name)): Path<(String, String)>,
) -> Result<String> {
require_admin(authed.is_admin, &authed.username)?;
let mut tx = db.begin().await?;
let deleted = sqlx::query_scalar!(
"DELETE FROM ai_skill WHERE workspace_id = $1 AND name = $2 RETURNING name",
&w_id,
&name
)
.fetch_optional(&mut *tx)
.await?;
if deleted.is_none() {
tx.commit().await?;
return Err(Error::NotFound(format!(
"no skill named {name:?} in workspace {w_id}"
)));
}
audit_log(
&mut *tx,
&authed,
"ai_skills.delete",
ActionKind::Delete,
&w_id,
Some(&name),
None,
)
.await?;
tx.commit().await?;
Ok(format!("Deleted skill {name} from workspace {w_id}"))
}
#[cfg(test)]
mod tests {
use super::*;
fn skill() -> SkillUpload {
SkillUpload {
name: "test-skill".to_string(),
description: "Useful for tests".to_string(),
instructions: "# Test\n\nDo the thing.".to_string(),
}
}
#[test]
fn validate_skill_rejects_oversized_description() {
let mut skill = skill();
skill.description = "x".repeat(MAX_SKILL_DESCRIPTION_CHARS + 1);
assert!(matches!(validate_skill(&skill), Err(Error::BadRequest(_))));
}
#[test]
fn validate_skill_rejects_oversized_instructions() {
let mut skill = skill();
skill.instructions = "x".repeat(MAX_SKILL_INSTRUCTIONS_BYTES + 1);
assert!(matches!(validate_skill(&skill), Err(Error::BadRequest(_))));
}
#[test]
fn validate_skill_rejects_oversized_name() {
let mut skill = skill();
skill.name = "a".repeat(MAX_SKILL_NAME_CHARS + 1);
assert!(matches!(validate_skill(&skill), Err(Error::BadRequest(_))));
}
#[test]
fn validate_skill_rejects_non_slug_name() {
// Uppercase, underscore, space and punctuation are all outside the
// Claude SKILL.md `[a-z0-9-]` name charset.
for bad in ["My-Skill", "my_skill", "my skill", "skill!"] {
let mut skill = skill();
skill.name = bad.to_string();
assert!(
matches!(validate_skill(&skill), Err(Error::BadRequest(_))),
"{bad:?} should be rejected"
);
}
}
#[test]
fn validate_skill_counts_description_in_characters() {
// 1024 two-byte chars exceed the byte limit but sit exactly on the
// character limit, so they must be accepted.
let mut skill = skill();
skill.description = "é".repeat(MAX_SKILL_DESCRIPTION_CHARS);
assert!(validate_skill(&skill).is_ok());
}
#[test]
fn workspace_capacity_allows_replacement_at_cap() {
// Already at the cap, but the upload only replaces an existing skill.
let at_cap = MAX_SKILLS_PER_WORKSPACE as i64;
assert!(check_workspace_skill_capacity(at_cap, 1, 1).is_ok());
}
#[test]
fn workspace_capacity_rejects_new_skill_over_cap() {
let at_cap = MAX_SKILLS_PER_WORKSPACE as i64;
assert!(matches!(
check_workspace_skill_capacity(at_cap, 0, 1),
Err(Error::BadRequest(_))
));
}
#[test]
fn collect_upload_names_trims_and_collects() {
let names = collect_upload_names(&[skill()]).unwrap();
assert_eq!(names, vec!["test-skill".to_string()]);
}
#[test]
fn collect_upload_names_rejects_duplicates() {
// Names are compared after trimming, so whitespace can't smuggle a dup in.
let dup = SkillUpload { name: " test-skill ".to_string(), ..skill() };
assert!(matches!(
collect_upload_names(&[skill(), dup]),
Err(Error::BadRequest(_))
));
}
}
File diff suppressed because it is too large Load Diff
+6 -2
View File
@@ -219,12 +219,16 @@ struct DatabaseCheckResult {
async fn check_database_with_latency(db: &DB) -> DatabaseCheckResult {
let start = std::time::Instant::now();
// `pg_is_in_recovery()` is true on standbys/read-only replicas, so a primary
// returns true here. A read-only replica (e.g. after a failover where the
// primary became a secondary) reports unhealthy, letting liveness probes
// restart the pod instead of silently failing all writes.
let healthy = tokio::time::timeout(
HEALTH_CHECK_TIMEOUT,
sqlx::query_scalar!("SELECT 1").fetch_one(db),
sqlx::query_scalar!("SELECT NOT pg_is_in_recovery()").fetch_one(db),
)
.await
.map(|r| r.is_ok())
.map(|r| matches!(r, Ok(Some(true))))
.unwrap_or(false);
let latency_ms = start.elapsed().as_millis() as i64;
+115 -7
View File
@@ -513,6 +513,26 @@ async fn cancel_job_api(
Path((w_id, id)): Path<(String, Uuid)>,
Json(CancelJob { reason }): Json<CancelJob>,
) -> error::Result<String> {
// App embed tokens (the sandboxed app iframe) may cancel ONLY jobs they launched
// — their app's component runs, stamped created_by == viewer. cancel_job_api has
// no other per-job ownership check, so without this an embed token (which carries
// the viewer's identity) could cancel any job by id. NotFound (not 403) so the
// untrusted app can't probe job existence.
if let Some(authed) = opt_authed.as_ref() {
if windmill_api_auth::scopes::has_app_embed_sentinel(authed.scopes.as_deref()) {
let created_by = sqlx::query_scalar!(
"SELECT created_by FROM v2_job WHERE id = $1 AND workspace_id = $2",
id,
&w_id
)
.fetch_optional(&db)
.await?;
if created_by.as_deref() != Some(authed.username.as_str()) {
return Err(Error::NotFound(format!("Job {id} not found")));
}
}
}
let tx = db.begin().await?;
let audit_author: AuditAuthor = match opt_authed.as_ref() {
@@ -1007,6 +1027,17 @@ async fn require_job_read_access(
return Ok(());
}
// App embed tokens (the sandboxed app iframe) carry the viewer's identity so the
// app can read its own component runs — which are stamped `created_by == viewer`
// and so already returned above. They must NOT inherit the viewer's *broader*
// job access (share links, folder ACLs, admin RLS): user-authored app JS holds
// this token, and letting it reach any job merely visible to the viewer would
// expose unrelated runs' results/logs. Stop at the launched-by-viewer grant.
// NotFound (not PermissionDenied) so the untrusted app can't probe job existence.
if windmill_api_auth::scopes::has_app_embed_sentinel(authed.scopes.as_deref()) {
return Err(Error::NotFound(format!("Job {job_id} not found")));
}
// `username_override` is derived from the token *label* (`username_override_from_label`),
// which is fully user-controlled with no uniqueness/ownership check (webhook-/http-/
// email-/ws- trigger tokens, `ephemeral-script-end-user-*`, and the generic `label-*`
@@ -4022,11 +4053,17 @@ fn conditionally_require_authed_user(
}
pub async fn create_job_signature(
_authed: ApiAuthed,
authed: ApiAuthed,
Extension(db): Extension<DB>,
Path((w_id, job_id, resume_id)): Path<(String, Uuid, u32)>,
Query(approver): Query<QueryApprover>,
) -> error::Result<String> {
// The HMAC is treated as full authority by the resume endpoints, so minting
// it requires run scope on the suspended job's flow — not merely any
// jobs:run scope. No-op for unscoped tokens (incl. the in-flow substep token
// used by wmill.get_resume_urls()).
let flow_path = resume_target_flow_path(&db, &w_id, job_id).await?;
check_scopes(&authed, || format!("jobs:run:flows:{}", flow_path))?;
let key = get_workspace_key(&w_id, &db).await?;
create_signature(key, job_id, resume_id, approver.approver)
}
@@ -4109,11 +4146,17 @@ fn build_resume_url(
}
pub async fn get_resume_urls(
_authed: ApiAuthed,
authed: ApiAuthed,
Extension(db): Extension<DB>,
Path((w_id, job_id, resume_id)): Path<(String, Uuid, u32)>,
Query(approver): Query<QueryApprover>,
) -> error::JsonResult<ResumeUrls> {
// These URLs embed a resume signature (full resume capability), so a scoped
// token must hold run scope on the suspended job's flow. No-op for unscoped
// tokens (incl. the in-flow substep token). Trusted internal callers use
// get_resume_urls_internal directly and are unaffected.
let flow_path = resume_target_flow_path(&db, &w_id, job_id).await?;
check_scopes(&authed, || format!("jobs:run:flows:{}", flow_path))?;
get_resume_urls_internal(
Extension(db),
Path((w_id, job_id, resume_id)),
@@ -4180,6 +4223,46 @@ pub async fn get_resume_urls_internal(
Ok(Json(res))
}
/// Resolve the runnable path of the flow a (possibly step) job belongs to, used
/// to scope-check resume-signature minting against `jobs:run:flows:<path>`.
/// Returns an empty string when the path can't be resolved (e.g. previews or an
/// unknown job); an empty path only matters for path-restricted tokens, which
/// would not be running such a flow. Never hard-fails, so it can't break resume
/// for unscoped tokens (the in-flow `get_resume_urls()` path).
async fn resume_target_flow_path(db: &DB, w_id: &str, job_id: Uuid) -> error::Result<String> {
let job = sqlx::query!(
r#"SELECT kind::text as "kind!", parent_job, runnable_path
FROM v2_job WHERE id = $1 AND workspace_id = $2"#,
job_id,
w_id
)
.fetch_optional(db)
.await?;
let Some(job) = job else {
return Ok(String::new());
};
// All flow kinds: the job itself is the flow whose path scopes the resume.
if matches!(
job.kind.as_str(),
"flow" | "flowpreview" | "flownode" | "singlestepflow"
) {
return Ok(job.runnable_path.unwrap_or_default());
}
// Otherwise it's a step; its parent is the flow.
if let Some(parent) = job.parent_job {
return Ok(sqlx::query_scalar!(
"SELECT runnable_path FROM v2_job WHERE id = $1 AND workspace_id = $2",
parent,
w_id
)
.fetch_optional(db)
.await?
.flatten()
.unwrap_or_default());
}
Ok(job.runnable_path.unwrap_or_default())
}
/// Get the flow ID for a job. If the job is a flow, returns the job_id.
/// If the job is a step in a flow, returns the parent flow ID.
async fn get_flow_id_for_job(db: &DB, job_id: Uuid) -> error::Result<Uuid> {
@@ -9488,16 +9571,31 @@ mod approval_view_gate_tests {
fn anonymous_cannot_view_when_auth_required() {
// The regression: an unauthenticated holder of the approval token must see nothing.
let c = Some(conds(true, vec![]));
assert!(!can_view(&None, &c, Some("f/team/flow"), "trigger@example.com"));
assert!(!can_view(
&None,
&c,
Some("f/team/flow"),
"trigger@example.com"
));
}
#[test]
fn anonymous_can_view_when_no_auth_required() {
// Unchanged behaviour: token alone is sufficient when auth isn't required.
let c = Some(conds(false, vec![]));
assert!(can_view(&None, &c, Some("f/team/flow"), "trigger@example.com"));
assert!(can_view(
&None,
&c,
Some("f/team/flow"),
"trigger@example.com"
));
// No approval conditions at all also allows token-only view.
assert!(can_view(&None, &None, Some("f/team/flow"), "trigger@example.com"));
assert!(can_view(
&None,
&None,
Some("f/team/flow"),
"trigger@example.com"
));
}
#[test]
@@ -9522,7 +9620,17 @@ mod approval_view_gate_tests {
let member = Some(authed("carol", false, vec!["approvers".to_string()]));
let outsider = Some(authed("dave", false, vec!["other".to_string()]));
// Use a non-owned folder path so ownership doesn't short-circuit the check.
assert!(can_view(&member, &c, Some("f/team/flow"), "trigger@example.com"));
assert!(!can_view(&outsider, &c, Some("f/team/flow"), "trigger@example.com"));
assert!(can_view(
&member,
&c,
Some("f/team/flow"),
"trigger@example.com"
));
assert!(!can_view(
&outsider,
&c,
Some("f/team/flow"),
"trigger@example.com"
));
}
}
+34 -5
View File
@@ -66,6 +66,7 @@ use crate::scim_oss::has_scim_token;
use windmill_common::error::AppError;
mod ai;
mod ai_skills;
mod apps;
pub mod args;
mod audit;
@@ -545,7 +546,15 @@ pub async fn run_server(
Router::new()
// Reordered alphabetically
.nest("/acls", granular_acls::workspaced_service())
.nest("/apps", apps::workspaced_service(request_size_limit * 5))
// CORS so the opaque-origin in-workspace app viewer (WIN-2006,
// sandboxed /apps/get) can read the app definition by path
// (apps/get/p, apps/embed_token/p) with a scoped embed token.
// Bearer-token-only (no cookies), consistent with the other
// workspaced services the iframe calls.
.nest(
"/apps",
apps::workspaced_service(request_size_limit * 5).layer(cors.clone()),
)
.nest("/assets", windmill_api_assets::workspaced_service())
.nest("/audit", audit::workspaced_service())
.nest("/capture", capture::workspaced_service())
@@ -565,7 +574,13 @@ pub async fn run_server(
"/flow_conversations",
windmill_api_flow_conversations::workspaced_service(),
)
.nest("/folders", folders::workspaced_service())
// CORS so an opaque-origin app iframe (WIN-2006 embed,
// no separate domain) can read folders/listnames with a
// scoped embed token. Consistent with apps_u/jobs_u cors.
.nest(
"/folders",
folders::workspaced_service().layer(cors.clone()),
)
.nest("/folders_history", folder_history::workspaced_service())
.nest("/groups", groups::workspaced_service())
.nest("/groups_history", group_history::workspaced_service())
@@ -608,20 +623,30 @@ pub async fn run_server(
Router::new()
})
.nest("/ai", ai::workspaced_service())
.nest("/ai_skills", ai_skills::workspaced_service())
.nest("/npm_proxy", windmill_api_npm_proxy::workspaced_service())
.nest(
"/path_autocomplete",
path_autocomplete::workspaced_service(),
)
.nest("/raw_apps", raw_apps::workspaced_service())
.nest("/resources", resources::workspaced_service())
// CORS so the opaque-origin app iframe can read
// resources/list, resources/type/* with a scoped token.
.nest(
"/resources",
resources::workspaced_service().layer(cors.clone()),
)
.nest("/shared_ui", workspace_shared_ui::workspaced_service())
.nest("/schedules", windmill_api_schedule::workspaced_service())
.nest("/scripts", scripts::workspaced_service())
.nest("/trash", trash::workspaced_service())
.nest(
"/users",
users::workspaced_service().layer(Extension(argon2.clone())),
// CORS so the opaque-origin app iframe can read
// users/whoami with a scoped embed token.
users::workspaced_service()
.layer(Extension(argon2.clone()))
.layer(cors.clone()),
)
.nest("/variables", variables::workspaced_service())
.nest("/volumes", volumes_oss::workspaced_service())
@@ -727,7 +752,11 @@ pub async fn run_server(
.nest("/apps_u", {
#[cfg(feature = "enterprise")]
{
apps_oss::global_unauthed_service()
// CORS so the opaque-origin app viewer (WIN-2006 embed, no
// separate domain) can load a custom-path public app via
// public_app_by_custom_path cross-origin. Consistent with
// the workspaced /w/{workspace_id}/apps_u mount below.
apps_oss::global_unauthed_service().layer(cors.clone())
}
#[cfg(not(feature = "enterprise"))]
@@ -18,6 +18,7 @@ use windmill_common::{
};
use crate::db::ApiAuthed;
use windmill_mcp::parse_mcp_scopes;
/// Token expiration for MCP OAuth tokens (1 week in seconds)
const MCP_OAUTH_TOKEN_EXPIRATION_SECS: u64 = 7 * 24 * 60 * 60;
@@ -585,6 +586,8 @@ async fn handle_refresh_token_grant(
Some(&new_access_token)
};
let new_refresh_token = rd_string(32);
// Re-issues the already-approved (hence already-contained) scopes verbatim;
// containment is enforced once at approval time, so no re-check here.
let scopes = token_row.scopes;
// Create new access token (rejects archived workspaces inline)
@@ -820,6 +823,40 @@ async fn oauth_approve_inner(
.map(|s| s.to_string())
.collect();
// The approver's own token bounds what it may grant: a scope-restricted MCP
// token must not approve a broader one (e.g. mcp:scripts:f/x -> mcp:all). An
// unrestricted approver (interactive session, scopes None) grants freely,
// which is the normal consent flow. This is the legitimate MCP-narrowing
// path, so it uses MCP-pattern containment rather than the byte-identical
// rule ensure_scopes_within_caller applies on the generic token endpoints.
let caller_restricted = authed
.scopes
.as_deref()
.is_some_and(|s| s.iter().any(|x| !x.starts_with("if_jobs:filter_tags:")));
if caller_restricted {
// An empty grant would mint a token the auth layer treats as unscoped
// (full privileges), so a restricted approver must not produce one.
if scopes.is_empty() {
return Err(Error::NotAuthorized(
"A scope-restricted token cannot approve an empty scope grant".to_string(),
));
}
if scopes.iter().any(|s| !s.starts_with("mcp:")) {
return Err(Error::NotAuthorized(
"A scope-restricted token can only approve MCP (mcp:*) scopes".to_string(),
));
}
let caller_config = parse_mcp_scopes(authed.scopes.as_deref().unwrap_or(&[]))
.map_err(|e| Error::InternalErr(format!("Failed to parse caller MCP scopes: {e}")))?;
let requested_config = parse_mcp_scopes(&scopes)
.map_err(|e| Error::BadRequest(format!("Failed to parse requested MCP scopes: {e}")))?;
if !caller_config.contains(&requested_config) {
return Err(Error::NotAuthorized(
"Requested scopes exceed the approving token's own MCP scopes".to_string(),
));
}
}
sqlx::query!(
"INSERT INTO mcp_oauth_server_code
(code, client_id, user_email, workspace_id, scopes, redirect_uri, code_challenge, code_challenge_method)
+27 -1
View File
@@ -455,9 +455,35 @@ pub async fn create_http_request(
}
};
// Bound the minted JWT to exactly this proxied route so a scope-restricted
// MCP token can't be widened into a full-privilege blank check. The
// endpoint-name gate (in the MCP runner) already authorized *which* endpoint
// may be called; this constrains what the resulting request can do. Unscoped
// callers (cookie / full-privilege tokens) keep an unscoped JWT to preserve
// existing behavior. A scope-restricted caller whose route can't be resolved
// fails closed.
let caller_restricted = api_authed
.scopes
.as_deref()
.is_some_and(|s| s.iter().any(|x| !x.starts_with("if_jobs:filter_tags:")));
let scopes = if caller_restricted {
let parsed = reqwest::Url::parse(url)
.map_err(|e| ErrorData::internal_error(format!("Invalid proxied URL: {}", e), None))?;
let scope =
windmill_api_auth::scopes::scope_for_route(method, parsed.path()).ok_or_else(|| {
ErrorData::internal_error(
"Could not derive route scope for proxied MCP endpoint".to_string(),
None,
)
})?;
Some(vec![scope])
} else {
None
};
// Add authorization header
let authed = Authed::from(api_authed.clone());
let token = create_jwt_token(authed, workspace_id, 3600, None, None, None, None)
let token = create_jwt_token(authed, workspace_id, 3600, None, None, None, scopes)
.await
.map_err(|e| ErrorData::internal_error(e.to_string(), None))?;
request_builder = request_builder.header("Authorization", format!("Bearer {}", token));
+1
View File
@@ -98,6 +98,7 @@ fn build_standard_scope_domains() -> Vec<ScopeDomain> {
("configs", "Configs", "Configuration management", false),
("oauth", "OAuth", "OAuth management", false),
("ai", "AI", "AI feature management", false),
("ai_skills", "AI Skills", "AI skill management", false),
(
"agent_workers",
"Agent Workers",
@@ -12,6 +12,8 @@ use crate::db::ApiAuthed;
use crate::{apps::AppWithLastVersion, db::DB, folders::Folder};
use windmill_api_auth::check_scopes;
#[cfg(any(
feature = "http_trigger",
feature = "websocket",
@@ -582,6 +584,18 @@ pub(crate) async fn tarball_workspace(
skip_resources
);
// The route is gated by workspaces:read, but exporting DECRYPTED secrets is a
// variable-read capability beyond workspace metadata. Require variables:read
// only on the plaintext-secret path: ordinary tarball pulls (structure and
// encrypted-only values) keep working with workspaces:read, and the workspace
// key itself stays admin-only (include_key). No-op for unscoped tokens.
if plain_secret.or(plain_secrets).unwrap_or(false)
&& !skip_secrets.unwrap_or(false)
&& !skip_variables.unwrap_or(false)
{
check_scopes(&authed, || "variables:read".to_string())?;
}
// Opt-in behavior for surfacing per-resource ACLs on flow/app rows.
// Folder and group rows have always carried `extra_perms` in source and
// continue to do so unconditionally (`KeepEvenEmpty`) so existing
@@ -594,6 +608,24 @@ pub(crate) async fn tarball_workspace(
let mut tx = user_db.begin(&authed).await?;
// Exporting decrypted secrets in bulk is the same capability as a per-item
// secret read, so record it for parity with variables.decrypt_secret.
if plain_secret.or(plain_secrets).unwrap_or(false)
&& !skip_variables.unwrap_or(false)
&& !skip_secrets.unwrap_or(false)
{
windmill_audit::audit_oss::audit_log(
&mut *tx,
&authed,
"variables.decrypt_secret",
windmill_audit::ActionKind::Execute,
&w_id,
Some("workspace_tarball_export"),
None,
)
.await?;
}
// Source-of-truth for fork-ness: the workspace's parent_workspace_id column.
// The wm-fork-* prefix is a creation-time naming convention that could in
// principle drift (rename, manual SQL); the column is the contract that
+112
View File
@@ -38,6 +38,71 @@ impl McpScopeConfig {
is_resource_allowed(path, patterns)
}
/// Directional subset check: does this config grant at least everything
/// `requested` grants? Used to enforce monotonic containment when an MCP
/// OAuth approval mints a token (the granted scopes must be within the
/// approving token's own scopes).
///
/// Unlike `is_allowed` (which tests a single concrete path with OR
/// semantics), this requires every requested pattern to be covered by some
/// caller pattern — so `mcp:scripts:f/x` cannot widen into `mcp:scripts:*`.
pub fn contains(&self, requested: &McpScopeConfig) -> bool {
if self.all {
return true;
}
if requested.all {
return false;
}
if requested.favorites && !self.favorites {
return false;
}
if let Some(req_hub) = requested.hub_apps.as_ref() {
match self.hub_apps.as_ref() {
Some(caller_hub) => {
let caller_apps: std::collections::HashSet<&str> =
caller_hub.split(',').map(|s| s.trim()).collect();
if !req_hub
.split(',')
.map(|s| s.trim())
.all(|a| caller_apps.contains(a))
{
return false;
}
}
None => return false,
}
}
resource_list_covers(&self.scripts, &requested.scripts)
&& resource_list_covers(&self.flows, &requested.flows)
&& resource_list_covers(&self.endpoints, &requested.endpoints)
}
}
/// Every requested pattern must be covered by some caller pattern.
fn resource_list_covers(caller: &[String], requested: &[String]) -> bool {
requested
.iter()
.all(|req| caller.iter().any(|c| pattern_covers(c, req)))
}
/// Directional: does the single caller pattern cover `requested`? `caller` may
/// be `*`, an exact path/name, or a `<prefix>/*` subtree; `requested` may itself
/// be a subtree wildcard, in which case the whole requested subtree must fall
/// within the caller's. Mirrors the route-scope containment in windmill-api-auth.
fn pattern_covers(caller: &str, requested: &str) -> bool {
if caller == "*" || caller == requested {
return true;
}
// An exact caller pattern only covers itself (handled above); a wildcard
// requested can never be covered by a non-`*` exact caller.
let Some(prefix) = caller.strip_suffix("/*") else {
return false;
};
let requested_base = requested.strip_suffix("/*").unwrap_or(requested);
requested_base == prefix
|| (requested_base.starts_with(prefix)
&& requested_base.as_bytes().get(prefix.len()) == Some(&b'/'))
}
/// Parse MCP scopes from token scope strings
@@ -254,4 +319,51 @@ mod tests {
assert!(config.is_allowed("flow", "f/automation/test"));
assert!(!config.is_allowed("flow", "f/other/test"));
}
fn cfg(scopes: &[&str]) -> McpScopeConfig {
parse_mcp_scopes(&scopes.iter().map(|s| s.to_string()).collect::<Vec<_>>()).unwrap()
}
#[test]
fn test_contains_subset_and_widening() {
// mcp:all contains anything.
assert!(cfg(&["mcp:all"]).contains(&cfg(&["mcp:scripts:f/x"])));
assert!(cfg(&["mcp:all"]).contains(&cfg(&["mcp:all"])));
// A wildcard caller covers narrower requests, but not other domains/all.
let star = cfg(&["mcp:scripts:*"]);
assert!(star.contains(&cfg(&["mcp:scripts:f/x"])));
assert!(star.contains(&cfg(&["mcp:scripts:*"])));
assert!(!star.contains(&cfg(&["mcp:all"])));
assert!(!star.contains(&cfg(&["mcp:flows:f/x"])));
// The core regression: a single-path caller must NOT widen into `*` or
// into another path.
let narrow = cfg(&["mcp:scripts:f/x"]);
assert!(narrow.contains(&cfg(&["mcp:scripts:f/x"])));
assert!(!narrow.contains(&cfg(&["mcp:scripts:*"])));
assert!(!narrow.contains(&cfg(&["mcp:scripts:f/y"])));
assert!(!narrow.contains(&cfg(&["mcp:all"])));
// Subtree wildcard covers paths within it but not a sibling subtree.
let subtree = cfg(&["mcp:scripts:f/team/*"]);
assert!(subtree.contains(&cfg(&["mcp:scripts:f/team/sub"])));
assert!(subtree.contains(&cfg(&["mcp:scripts:f/team/sub/*"])));
assert!(!subtree.contains(&cfg(&["mcp:scripts:f/other/x"])));
}
#[test]
fn test_contains_favorites_and_endpoints() {
assert!(cfg(&["mcp:favorites"]).contains(&cfg(&["mcp:favorites"])));
// A caller without favorites cannot grant favorites.
assert!(!cfg(&["mcp:scripts:*"]).contains(&cfg(&["mcp:favorites"])));
// Endpoint names match exactly (or via `*`).
let ep = cfg(&["mcp:endpoints:getVariable"]);
assert!(ep.contains(&cfg(&["mcp:endpoints:getVariable"])));
assert!(!ep.contains(&cfg(&["mcp:endpoints:getResource"])));
assert!(!ep.contains(&cfg(&["mcp:all"])));
// mcp:all grants all endpoints.
assert!(cfg(&["mcp:all"]).contains(&cfg(&["mcp:endpoints:getResource"])));
}
}
+4
View File
@@ -53,3 +53,7 @@ futures.workspace = true
chrono.workspace = true
reqwest.workspace = true
anyhow.workspace = true
base64.workspace = true
[dev-dependencies]
magic-crypt.workspace = true
+6
View File
@@ -1634,6 +1634,12 @@ async fn update_resource(
let path = path.to_path();
check_scopes(&authed, || format!("resources:write:{}", path))?;
// A rename moves the resource (and its linked variable) to ns.path, so the
// destination must also be within the token's write scope, not just the
// source path.
if let Some(npath) = ns.path.as_deref() {
check_scopes(&authed, || format!("resources:write:{}", npath))?;
}
if let RuleCheckResult::Blocked(msg) = check_deploy_rules(
&w_id,
AuditAuthorable::username(&authed),
+106 -2
View File
@@ -14,8 +14,8 @@ use windmill_common::db::DB;
use windmill_common::workspaces::{check_deploy_rules, RuleCheckResult};
use crate::secret_backend_ext::{
delete_secret_from_backend, get_secret_value, is_vault_stored_value, rename_vault_secret,
store_secret_value,
delete_secret_from_backend, get_secret_value, is_external_stored_value, is_vault_stored_value,
rename_vault_secret, store_secret_value,
};
use windmill_common::utils::{escape_ilike_pattern, BulkDeleteRequest};
use windmill_common::webhook::{WebhookMessage, WebhookShared};
@@ -25,6 +25,7 @@ use axum::{
routing::{delete, get, post},
Json, Router,
};
use base64::{engine::general_purpose::STANDARD, Engine as _};
use futures::future::try_join_all;
use hyper::StatusCode;
use serde_json::Value;
@@ -535,6 +536,35 @@ async fn check_path_conflict(db: &DB, w_id: &str, path: &str) -> Result<()> {
return Ok(());
}
/// Reject a secret value flagged as already-encrypted (`already_encrypted=true`)
/// that is not actually workspace-key ciphertext — e.g. plaintext mistakenly
/// pushed as encrypted. Storing plaintext in the encrypted `value` column
/// silently bricks the variable: every later read fails to decrypt it.
///
/// The check is purely structural and never decrypts, so it cannot act as a
/// decryption/padding oracle for a caller who can write but not read secrets.
/// `encrypt` (AES-256-CBC) always yields standard base64 decoding to a non-zero
/// multiple of the 16-byte block size; anything else cannot be our ciphertext.
/// Values stored by an external backend ($vault:/$aws_sm:/$azure_kv: markers)
/// are not workspace ciphertext and are passed through untouched.
fn validate_already_encrypted_secret(path: &str, value: &str) -> Result<()> {
if is_external_stored_value(value) {
return Ok(());
}
let looks_like_ciphertext = STANDARD
.decode(value)
.map(|bytes| !bytes.is_empty() && bytes.len() % 16 == 0)
.unwrap_or(false);
if !looks_like_ciphertext {
return Err(Error::BadRequest(format!(
"Variable {path} was sent as already-encrypted (already_encrypted=true) but its \
value is not valid workspace-encrypted ciphertext. To push a plaintext secret, \
send it without already_encrypted (CLI: use --plain-secrets) so it gets encrypted."
)));
}
Ok(())
}
async fn create_variable(
authed: ApiAuthed,
Extension(db): Extension<DB>,
@@ -585,6 +615,11 @@ async fn create_variable(
// Use secret backend for encryption (supports both DB and Vault)
store_secret_value(&db, &w_id, &variable.path, &plain).await?
} else {
if variable.is_secret {
// already_encrypted == true: value is stored verbatim, so it must be
// ciphertext and not plaintext mislabeled as encrypted.
validate_already_encrypted_secret(&variable.path, &variable.value)?;
}
variable.value
};
@@ -1037,6 +1072,12 @@ async fn update_variable(
let path = path.to_path();
check_scopes(&authed, || format!("variables:write:{}", path))?;
// A rename moves the (possibly secret) variable to ns.path, so the
// destination must also be within the token's write scope, not just the
// source path.
if let Some(npath) = ns.path.as_deref() {
check_scopes(&authed, || format!("variables:write:{}", npath))?;
}
let authed = maybe_refresh_folders(&path, &w_id, authed, &db).await;
let mut sqlb = SqlBuilder::update_table("variable");
@@ -1076,6 +1117,11 @@ async fn update_variable(
// Store at target_path (new path if renaming, otherwise current path)
store_secret_value(&db, &w_id, target_path, &plain).await?
} else {
if is_secret {
// already_encrypted == true: value is stored verbatim, so it must
// be ciphertext and not plaintext mislabeled as encrypted.
validate_already_encrypted_secret(target_path, &nvalue)?;
}
nvalue
};
sqlb.set_str("value", &value);
@@ -1507,3 +1553,61 @@ pub async fn get_value_internal<'a>(
Ok(r)
}
#[cfg(test)]
mod tests {
use super::*;
use magic_crypt::MagicCryptTrait;
#[test]
fn accepts_real_workspace_ciphertext() {
// The exact shape produced by `encrypt` (AES-256-CBC, base64).
let mc = magic_crypt::new_magic_crypt!("a-test-workspace-key", 256);
for plain in [
"",
"original-secret",
"some: plaintext\n",
"a".repeat(500).as_str(),
] {
let ciphertext = mc.encrypt_str_to_base64(plain);
assert!(
validate_already_encrypted_secret("f/x/cfg", &ciphertext).is_ok(),
"should accept genuine ciphertext for plaintext {plain:?}: {ciphertext}"
);
}
}
#[test]
fn rejects_plaintext_mislabeled_as_encrypted() {
// Plaintext mislabeled as encrypted: storing it verbatim would make the
// variable undecryptable on every read, so it must be rejected.
for plaintext in [
"some: plaintext\n",
"original-secret",
"hunter2",
"{\"a\": 1}",
"not base64!!",
" leading-space",
] {
assert!(
validate_already_encrypted_secret("f/x/cfg", plaintext).is_err(),
"should reject plaintext mislabeled as encrypted: {plaintext:?}"
);
}
}
#[test]
fn rejects_empty_and_non_block_aligned() {
// Valid base64 but not a whole number of AES blocks -> cannot be our ciphertext.
assert!(validate_already_encrypted_secret("p", "").is_err());
assert!(validate_already_encrypted_secret("p", "dGVzdA==").is_err()); // "test" -> 4 bytes
}
#[test]
fn passes_through_external_backend_markers() {
// External secret backends store $-prefixed markers, not workspace ciphertext.
for marker in ["$vault:f/x/cfg", "$aws_sm:f/x/cfg", "$azure_kv:f/x/cfg"] {
assert!(validate_already_encrypted_secret("f/x/cfg", marker).is_ok());
}
}
}
+9 -2
View File
@@ -7,7 +7,7 @@ use axum::{extract::Path, routing::post, Extension, Json, Router};
use http::StatusCode;
use sqlx::PgConnection;
use std::collections::HashSet;
use windmill_api_auth::ApiAuthed;
use windmill_api_auth::{check_scopes, ApiAuthed};
use windmill_audit::{audit_oss::audit_log, ActionKind};
use windmill_common::global_settings::HTTP_ROUTE_WORKSPACED_ROUTE;
use windmill_common::{
@@ -262,6 +262,12 @@ pub async fn create_many_http_triggers(
let mut route_path_keys = Vec::with_capacity(new_http_triggers.len());
for new_http_trigger in new_http_triggers.iter() {
// Per-item write scope, matching the single-create handler. The bulk
// endpoint must not let a path-scoped token create triggers outside it.
check_scopes(&authed, || {
format!("http_triggers:write:{}", &new_http_trigger.base.path)
})?;
handler
.validate_new(&db, &w_id, &new_http_trigger.config)
.await
@@ -373,7 +379,8 @@ impl TriggerCrud for HttpTrigger {
const TABLE_NAME: &'static str = "http_trigger";
const TRIGGER_TYPE: &'static str = "http";
const DRAFT_KIND: windmill_common::user_drafts::UserDraftItemKind = windmill_common::user_drafts::UserDraftItemKind::TriggerHttp;
const DRAFT_KIND: windmill_common::user_drafts::UserDraftItemKind =
windmill_common::user_drafts::UserDraftItemKind::TriggerHttp;
const SUPPORTS_SERVER_STATE: bool = false;
const SUPPORTS_TEST_CONNECTION: bool = false;
const ROUTE_PREFIX: &'static str = "/http_triggers";
@@ -4,7 +4,7 @@ use async_trait::async_trait;
use itertools::Itertools;
use serde_json::value::RawValue;
use sqlx::{types::Json as SqlxJson, PgConnection};
use windmill_api_auth::ApiAuthed;
use windmill_api_auth::{check_scopes, ApiAuthed};
use windmill_common::DB;
use windmill_common::{
db::UserDB,
@@ -15,10 +15,39 @@ use windmill_git_sync::DeployedObject;
use windmill_trigger::{Trigger, TriggerCrud, TriggerData};
use super::{
get_url_from_runnable_value, proxy::connect_async_with_proxy, validate_websocket_url_for_ssrf,
TestWebsocketConfig, WebsocketConfig, WebsocketConfigRequest, WebsocketTrigger,
get_url_from_runnable_value, listener::InitialMessage, proxy::connect_async_with_proxy,
validate_websocket_url_for_ssrf, TestWebsocketConfig, WebsocketConfig, WebsocketConfigRequest,
WebsocketTrigger,
};
/// A websocket_triggers:write token can configure secondary runnables that the
/// listener later executes under the trigger owner's identity: a `$flow:`/
/// `$script:` URL resolver and `initial_messages` of kind `runnable_result`.
/// That execution happens in a background task where the reconstructed authed is
/// scopeless (so its check_scopes is a no-op), so enforce run scope here, at
/// create/update time, against the API caller's token.
fn check_secondary_runnable_scopes(
authed: &ApiAuthed,
config: &WebsocketConfigRequest,
) -> Result<()> {
if let Some(rest) = config.url.strip_prefix("$flow:") {
check_scopes(authed, || format!("jobs:run:flows:{}", rest))?;
} else if let Some(rest) = config.url.strip_prefix("$script:") {
check_scopes(authed, || format!("jobs:run:scripts:{}", rest))?;
}
if let Some(messages) = config.initial_messages.as_ref() {
for msg in messages {
if let Ok(InitialMessage::RunnableResult { path, is_flow, .. }) =
serde_json::from_value::<InitialMessage>(msg.clone())
{
let kind = if is_flow { "flows" } else { "scripts" };
check_scopes(authed, || format!("jobs:run:{}:{}", kind, path))?;
}
}
}
Ok(())
}
#[async_trait]
impl TriggerCrud for WebsocketTrigger {
type TriggerConfig = WebsocketConfig;
@@ -101,6 +130,7 @@ impl TriggerCrud for WebsocketTrigger {
w_id: &str,
trigger: TriggerData<Self::TriggerConfigRequest>,
) -> Result<()> {
check_secondary_runnable_scopes(authed, &trigger.config)?;
let resolved_edited_by = trigger.base.resolve_edited_by(authed);
let resolved_permissioned_as = trigger.base.resolve_permissioned_as(authed);
let filters = trigger
@@ -178,6 +208,7 @@ impl TriggerCrud for WebsocketTrigger {
path: &str,
trigger: TriggerData<Self::TriggerConfigRequest>,
) -> Result<()> {
check_secondary_runnable_scopes(authed, &trigger.config)?;
let resolved_edited_by = trigger.base.resolve_edited_by(authed);
let resolved_permissioned_as = trigger.base.resolve_permissioned_as(authed);
let filters = trigger
@@ -509,7 +509,7 @@ impl Clone for ReturnMessageChannels {
}
#[derive(Debug, Deserialize)]
enum InitialMessage {
pub(crate) enum InitialMessage {
#[serde(rename = "raw_message")]
RawMessage(String),
#[serde(rename = "runnable_result")]
+42 -2
View File
@@ -255,6 +255,14 @@ pub async fn do_duckdb(
} else {
None
};
// Parse the signature from the ORIGINAL script: managed materialize wraps
// the trailing SELECT and strips line comments, which drops the
// `-- $name (type)` arg declarations while their `$name` references
// survive in the embedded SELECT. Parsing args here (pre-wrap) keeps them
// declared so they are still bound — and s3object args translated to
// `s3://` URIs — at run time.
let sig = parse_duckdb_sig(query)?.args;
let materialized_query;
let query: &str = match &materialize {
Some((Some(rewritten), _)) => {
@@ -263,8 +271,6 @@ pub async fn do_duckdb(
}
_ => query,
};
let sig = parse_duckdb_sig(query)?.args;
let mut job_args = build_args_values(job, client, conn).await?;
let reserved_variables =
@@ -1106,6 +1112,40 @@ mod tests {
);
}
// Managed `// materialize` may take SQL args (e.g. an s3object uploaded on
// the run form). The wrap strips line comments — including the
// `-- $name (type)` declarations — so the executor parses the signature from
// the original script (done above, before the rewrite) while the `$name`
// references survive inside the wrapped SELECT. This pins both halves of that
// contract so a regression that drops either is caught.
#[test]
fn materialize_preserves_sql_args() {
let script = "-- materialize ducklake://main/rows\n\
-- $file (s3object)\n\
SELECT * FROM read_json_auto($file)";
// The signature is recoverable from the original (un-wrapped) script.
let sig = parse_duckdb_sig(script).expect("sig parses").args;
let file_arg = sig
.iter()
.find(|a| a.name == "file")
.expect("`$file` declared");
assert_eq!(file_arg.otyp.as_deref(), Some("s3object"));
// The wrapped query still references `$file`, so the parsed sig binds it.
let (rewritten, _) = build_materialized_query(script, None)
.expect("materialize builds")
.expect("materialize present");
let rewritten = rewritten.expect("managed mode rewrites the query");
assert!(
rewritten.contains("$file"),
"wrapped query must keep the `$file` reference, got:\n{rewritten}"
);
// The declaration comment is gone (wrap strips line comments) — which is
// exactly why the sig must come from the original, not the rewrite.
assert!(!rewritten.contains("-- $file"));
}
// Tests for parse_attach_db_resource function
#[test]
fn test_parse_attach_db_resource_postgres_res_prefix() {
+1 -1
View File
@@ -2,7 +2,7 @@ import { sleep } from "https://deno.land/x/sleep@v1.2.1/mod.ts";
import * as windmill from "https://deno.land/x/windmill@v1.174.0/mod.ts";
import * as api from "https://deno.land/x/windmill@v1.174.0/windmill-api/index.ts";
export const VERSION = "v1.735.0";
export const VERSION = "v1.737.0";
export async function login(email: string, password: string): Promise<string> {
return await windmill.UserService.login({
+74 -4
View File
@@ -99,6 +99,31 @@ export interface VariableFile {
is_oauth?: boolean;
}
/**
* Whether `value` has the structural shape of a workspace-encrypted secret
* (the form produced by `sync pull` without --plain-secrets), as opposed to a
* plaintext value a user authored by hand.
*
* Mirrors the server guard (windmill-store/src/variables.rs): workspace
* ciphertext (AES-256-CBC, base64) is standard base64 decoding to a non-zero
* multiple of the 16-byte block size. External secret-backend markers
* ($vault:/$aws_sm:/$azure_kv:) are stored verbatim too, so they count as
* already-encrypted. This is a shape check only it never decrypts.
*/
export function looksLikeWorkspaceCiphertext(value: string): boolean {
if (
value.startsWith("$vault:") ||
value.startsWith("$aws_sm:") ||
value.startsWith("$azure_kv:")
) {
return true;
}
if (value.length === 0 || value.length % 4 !== 0) return false;
if (!/^[A-Za-z0-9+/]+={0,2}$/.test(value)) return false;
const decodedLen = Buffer.from(value, "base64").length;
return decodedLen > 0 && decodedLen % 16 === 0;
}
export async function pushVariable(
workspace: string,
remotePath: string,
@@ -106,6 +131,11 @@ export async function pushVariable(
localVariable: VariableFile,
plainSecrets: boolean,
wsSpecific?: boolean,
// Whether a secret->non-secret downgrade may be applied. Only an authoritative
// single-file `variable push` sets this. Bulk `sync push` leaves it false: a
// pulled secret's spec value is ciphertext, and demoting it would store that
// ciphertext verbatim as a visible non-secret value.
allowSecretDowngrade: boolean = false,
): Promise<void> {
remotePath = removeType(remotePath, "variable");
log.debug(`Processing local variable ${remotePath}`);
@@ -130,14 +160,26 @@ export async function pushVariable(
log.debug(`Variable ${remotePath} is not up-to-date, updating`);
// Apply is_secret only when it differs from the remote (the value is always
// sent, so the server allows the flag change). Upgrades (non-secret->secret)
// always apply; downgrades only when explicitly allowed (single-file push) —
// see allowSecretDowngrade. `undefined` leaves the flag untouched.
let nextIsSecret: boolean | undefined = undefined;
if (localVariable.is_secret !== variable.is_secret) {
if (localVariable.is_secret) {
nextIsSecret = true;
} else if (allowSecretDowngrade) {
nextIsSecret = false;
}
}
await wmill.updateVariable({
workspace,
path: remotePath.replaceAll(SEP, "/"),
alreadyEncrypted: !plainSecrets,
requestBody: {
...localVariable,
is_secret:
localVariable.is_secret && !variable.is_secret ? true : undefined,
is_secret: nextIsSecret,
...(wsSpecific !== undefined ? { ws_specific: wsSpecific } : {}),
},
});
@@ -174,12 +216,40 @@ async function push(
log.info(colors.bold.yellow("Pushing variable..."));
const local = parseFromFile(filePath) as VariableFile;
// A secret value in a single-file push is authored by the user and is
// therefore plaintext that must be encrypted server-side — unless it has the
// shape of workspace ciphertext (a value round-tripped from `sync pull`).
// Pushing plaintext as already-encrypted would brick the variable. An explicit
// --plain-secrets always forces the plaintext (encrypt) path.
let plainSecrets = opts.plainSecrets ?? false;
if (opts.plainSecrets === undefined && local.is_secret) {
if (!looksLikeWorkspaceCiphertext(local.value)) {
log.info(
colors.yellow(
"Secret value is not in encrypted form; pushing as plaintext to be encrypted server-side (pass --plain-secrets to silence)."
)
);
plainSecrets = true;
} else {
// The value has the shape of workspace ciphertext, so it's stored as-is.
// A plaintext secret that coincidentally looks like ciphertext (e.g. a
// base64 token) would be stored unreadable, so surface the assumption.
log.warn(
"Secret value looks already-encrypted; pushing it as-is. If it is a plaintext secret, re-run with --plain-secrets so it gets encrypted."
);
}
}
await pushVariable(
workspace.workspaceId,
remotePath,
undefined,
parseFromFile(filePath),
opts.plainSecrets ?? false
local,
plainSecrets,
undefined,
true // single-file push is authoritative: allow secret->non-secret downgrade
);
log.info(colors.bold.underline.green(`Variable ${remotePath} pushed`));
}
+5 -5
View File
@@ -104,25 +104,25 @@ export async function requireLogin(
// 403 means the token authenticated but lacks scope — re-issuing
// won't help. Keep this distinct from the 401 message so the user
// doesn't waste time reproducing the token.
log.info(colors.red(
log.infoStderr(colors.red(
`Permission denied: the token is valid but lacks the required scope.${bodyStr ? `\n${bodyStr}` : ""}`
));
} else if (status === 401) {
log.info(colors.red(
log.infoStderr(colors.red(
`Could not authenticate with the provided credentials. Please check your --token and --base-url and try again.${bodyStr ? `\n${bodyStr}` : ""}`
));
} else {
log.info(colors.red(
log.infoStderr(colors.red(
`Request failed (${status ?? "unknown"}): ${bodyStr}`
));
}
return process.exit(1);
}
log.info(colors.red("Could not authenticate with the provided credentials. Please check your --token and --base-url and try again."));
log.infoStderr(colors.red("Could not authenticate with the provided credentials. Please check your --token and --base-url and try again."));
return process.exit(1);
}
log.info(
log.infoStderr(
"! Could not reach API given existing credentials. Attempting to reauth..."
);
const newToken = await loginInteractive(workspace.remote);
+1 -1
View File
@@ -10,4 +10,4 @@ export const WM_FORK_PREFIX = "wm-fork";
// (e.g. utils.ts) can read it without importing main.ts and creating a circular
// dependency (main → workspace → utils → main) that triggers a TDZ.
// Re-exported from main.ts for backwards compatibility.
export const VERSION = "1.735.0";
export const VERSION = "1.737.0";
+29 -29
View File
@@ -57,7 +57,7 @@ async function selectFromMultipleProfiles(
(p) => p.name === lastUsedProfileName
);
if (lastUsedProfile) {
log.info(
log.infoStderr(
colors.green(
`Using last used profile '${lastUsedProfile.name}' for ${context}`
)
@@ -69,7 +69,7 @@ async function selectFromMultipleProfiles(
// No last used or it no longer exists - prompt for selection
if (!!!process.stdin.isTTY || !!!process.stdout.isTTY) {
const selectedProfile = profiles[0];
log.info(
log.infoStderr(
colors.yellow(
`Multiple profiles found for ${context}. Using first available profile: '${selectedProfile.name}'`
)
@@ -87,7 +87,7 @@ async function selectFromMultipleProfiles(
return selectedProfile;
}
log.info(
log.infoStderr(
colors.yellow(`\nMultiple workspace profiles found for ${context}:`)
);
@@ -125,14 +125,14 @@ async function createWorkspaceProfileInteractively(
): Promise<Workspace | undefined> {
// Log appropriate message based on context
if (!context.isForked) {
log.info(
log.infoStderr(
colors.yellow(
`\nNo workspace profile found for branch '${context.rawBranch}'\n` +
`(${normalizedBaseUrl}, ${workspaceId})`
)
);
} else {
log.info(
log.infoStderr(
colors.yellow(
`\nNo workspace profile was found for this forked workspace\n` +
`(${normalizedBaseUrl}, ${workspaceId})`
@@ -141,7 +141,7 @@ async function createWorkspaceProfileInteractively(
}
if (!!!process.stdin.isTTY || !!!process.stdout.isTTY) {
log.info(
log.infoStderr(
"Not a TTY, cannot create profile interactively. Use 'wmill workspace add' first."
);
return undefined;
@@ -187,12 +187,12 @@ async function createWorkspaceProfileInteractively(
opts.configDir
);
log.info(
log.infoStderr(
colors.green(
`✓ Created profile '${profileName}' for ${workspaceId} on ${normalizedBaseUrl}`
)
);
log.info(colors.green(`✓ Profile '${profileName}' is now active`));
log.infoStderr(colors.green(`✓ Profile '${profileName}' is now active`));
return newWorkspace;
}
@@ -244,7 +244,7 @@ async function tryResolveWorkspace(
`workspace '${opts.workspace}'`,
opts.configDir
);
log.info(
log.infoStderr(
colors.green(
`Using workspace profile '${selected.name}' for workspace '${opts.workspace}' (${workspaceId} on ${normalizedBaseUrl})`
)
@@ -254,7 +254,7 @@ async function tryResolveWorkspace(
}
// No matching profile — offer to create one
log.info(
log.infoStderr(
`No profile found for workspace '${opts.workspace}' (${workspaceId} on ${normalizedBaseUrl})`
);
const ws = await createWorkspaceProfileInteractively(
@@ -309,7 +309,7 @@ export async function tryResolveBranchWorkspace(
wsEntry = config.workspaces?.[workspaceNameOverride] as WorkspaceEntryConfig | undefined;
if (wsEntry) {
wsName = workspaceNameOverride;
log.info(`Using workspace override: ${workspaceNameOverride}`);
log.infoStderr(`Using workspace override: ${workspaceNameOverride}`);
}
} else {
// Only try branch-based resolution if in a Git repository
@@ -328,7 +328,7 @@ export async function tryResolveBranchWorkspace(
const branchToLookup = originalBranchIfForked ?? rawBranch;
if (originalBranchIfForked) {
log.info(
log.infoStderr(
`Using original branch \`${originalBranchIfForked}\` for finding workspace from workspaces section in wmill.yaml`
);
}
@@ -346,7 +346,7 @@ export async function tryResolveBranchWorkspace(
if (!wsEntry.baseUrl) {
if (workspaceNameOverride) {
// User explicitly asked for this workspace but it has no baseUrl
log.warn(
log.warnStderr(
`⚠️ Workspace '${wsName}' has no baseUrl configured. Cannot resolve a profile.\n` +
` Add baseUrl to workspace '${wsName}' in wmill.yaml, or use --base-url flag.`
);
@@ -370,7 +370,7 @@ export async function tryResolveBranchWorkspace(
reason = `matched current git branch '${rawBranch}'`;
}
log.info(
log.infoStderr(
`Using workspace '${wsName}' (${reason}) → ${workspaceId} on ${baseUrl}`
);
@@ -406,7 +406,7 @@ export async function tryResolveBranchWorkspace(
if (matchingProfiles.length === 1) {
selectedProfile = matchingProfiles[0];
log.info(
log.infoStderr(
colors.green(
`Using workspace profile '${selectedProfile.name}' for workspace '${wsName}' with workspace id \`${workspaceId}\``
)
@@ -424,7 +424,7 @@ export async function tryResolveBranchWorkspace(
(p) => p.name === lastUsedName
);
if (lastUsedProfile) {
log.info(
log.infoStderr(
colors.green(
`Using workspace profile '${lastUsedProfile.name}' for workspace '${wsName}' (last used)`
)
@@ -449,7 +449,7 @@ export async function tryResolveBranchWorkspace(
opts.configDir
);
log.info(
log.infoStderr(
colors.green(
`Using workspace profile '${selectedProfile.name}' for workspace '${wsName}'`
)
@@ -459,7 +459,7 @@ export async function tryResolveBranchWorkspace(
if (workspaceIdIfForked) {
selectedProfile.name = `${selectedProfile.name}/${workspaceIdIfForked}`;
selectedProfile.workspaceId = workspaceIdIfForked;
log.info(
log.infoStderr(
`Using fork workspace \`${workspaceIdIfForked}\` (parent: \`${workspaceId}\`) from branch \`${rawBranch}\``
);
}
@@ -480,7 +480,7 @@ export async function resolveWorkspace(
try {
normalizedBaseUrl = new URL(opts.baseUrl).toString();
} catch (error) {
log.info(colors.red(`Invalid base URL: ${opts.baseUrl}`));
log.infoStderr(colors.red(`Invalid base URL: ${opts.baseUrl}`));
return process.exit(-1);
}
@@ -514,7 +514,7 @@ export async function resolveWorkspace(
if (existingWorkspace) {
if (existingWorkspace.remote !== normalizedBaseUrl) {
log.info(
log.infoStderr(
colors.red(
`Base URL mismatch: --base-url is ${normalizedBaseUrl} but workspace profile "${opts.workspace}" uses ${existingWorkspace.remote}`
)
@@ -535,7 +535,7 @@ export async function resolveWorkspace(
token: opts.token,
};
} else {
log.info(
log.infoStderr(
colors.red(
"If you specify a base URL with --base-url, you must also specify a workspace (--workspace) and token (--token)."
)
@@ -555,7 +555,7 @@ export async function resolveWorkspace(
if (workspaceNameOverride || opts.workspace || !branch || !branch.startsWith(WM_FORK_PREFIX)) {
return workspace;
} else {
log.info(
log.infoStderr(
`Found an active workspace \`${workspace.name}\` but the branch name indicates this is a forked workspace. Ignoring active workspace and trying to resolve the correct workspace from the branch name \`${branch}\`. Use --workspace to override.`
);
}
@@ -572,9 +572,9 @@ export async function resolveWorkspace(
if (suggestions.length > 0) {
msg += ` Did you mean: ${suggestions.map((s) => `"${s.name}"`).join(", ")}?`;
}
log.info(colors.red.bold(msg));
log.infoStderr(colors.red.bold(msg));
if (profiles.length > 0) {
log.info("\nAvailable workspaces:");
log.infoStderr("\nAvailable workspaces:");
new Table()
.header(["name", "remote", "workspace id"])
.padding(2)
@@ -620,12 +620,12 @@ export async function resolveWorkspace(
if (wsNames.length === 1) {
pickedWsName = wsNames[0];
log.info(
log.infoStderr(
`Auto-selected workspace '${pickedWsName}' (only workspace in config).\n` +
`Use --workspace to override or 'wmill workspace bind' to add more workspaces.`
);
} else if (process.stdin.isTTY) {
log.info(
log.infoStderr(
`Multiple workspaces configured but none matched the current context.\n` +
`Configured workspaces:\n${wsListStr}\n` +
`Use --workspace to skip this prompt.`
@@ -675,7 +675,7 @@ export async function resolveWorkspace(
try {
normalizedBaseUrl = new URL(envBaseUrl).toString();
} catch {
log.info(colors.red(`Invalid BASE_INTERNAL_URL: ${envBaseUrl}`));
log.infoStderr(colors.red(`Invalid BASE_INTERNAL_URL: ${envBaseUrl}`));
return process.exit(-1);
}
log.debug(
@@ -691,7 +691,7 @@ export async function resolveWorkspace(
return ws;
}
log.info(colors.red.bold("No workspace given and no default set. Run 'wmill workspace add' to configure one."));
log.infoStderr(colors.red.bold("No workspace given and no default set. Run 'wmill workspace add' to configure one."));
return process.exit(-1);
}
@@ -746,7 +746,7 @@ export async function tryResolveVersion(
export function validatePath(path: string): boolean {
if (!(path.startsWith("g") || path.startsWith("u") || path.startsWith("f"))) {
log.info(
log.infoStderr(
colors.red(
"Given remote path looks invalid. Remote paths are typically of the form <u|g|f>/<username|group|folder>/..."
)
+15
View File
@@ -21,11 +21,26 @@ export function info(msg: unknown) {
console.log(`\x1b[34m${String(msg)}\x1b[39m`);
}
// Like `info` but written to stderr, for diagnostics (e.g. the workspace-profile
// banner printed on every command) that must not pollute stdout when a command's
// data output is piped or redirected (e.g. `wmill variable get path > file`).
export function infoStderr(msg: unknown) {
if (silentMode) return;
console.error(`\x1b[34m${String(msg)}\x1b[39m`);
}
export function warn(msg: unknown) {
if (silentMode) return;
console.log(`\x1b[33m${String(msg)}\x1b[39m`);
}
// Like `warn` but written to stderr; see `infoStderr` for why diagnostics must
// not land on stdout.
export function warnStderr(msg: unknown) {
if (silentMode) return;
console.error(`\x1b[33m${String(msg)}\x1b[39m`);
}
export function error(msg: unknown) {
console.error(`\x1b[31m${String(msg)}\x1b[39m`);
}
+4 -4
View File
@@ -10,7 +10,7 @@ import * as http from "node:http";
export async function loginInteractive(remote: string) {
let token: string | undefined;
if (!process.stdin.isTTY) {
log.info("Not a TTY, can't login interactively.");
log.infoStderr("Not a TTY, can't login interactively.");
return undefined;
}
if (
@@ -55,7 +55,7 @@ export async function browserLogin(
const port = await getPort.default({ port: env });
if (port == undefined) {
log.info(colors.red.underline("failed to aquire port"));
log.infoStderr(colors.red.underline("failed to aquire port"));
return undefined;
}
@@ -79,7 +79,7 @@ export async function browserLogin(
});
const url = `${baseUrl}user/cli?port=${port}`;
log.info(`Login by going to ${url}`);
log.infoStderr(`Login by going to ${url}`);
try {
open.default(url).catch((error) => {
@@ -88,7 +88,7 @@ export async function browserLogin(
);
});
log.info("Opened browser for you");
log.infoStderr("Opened browser for you");
} catch (error) {
console.error(
`Failed to open browser, please navigate to ${url}, error: ${error}`
@@ -0,0 +1,42 @@
import { expect, test } from "bun:test";
import { looksLikeWorkspaceCiphertext } from "../src/commands/variable/variable.ts";
// =============================================================================
// looksLikeWorkspaceCiphertext drives whether single-file `variable push` treats
// a secret's value as already-encrypted (store verbatim) or as plaintext to be
// encrypted server-side. It must agree with the server guard
// (validate_already_encrypted_secret in windmill-store/src/variables.rs): a value
// is "ciphertext shaped" iff it is an external-backend marker, or standard base64
// decoding to a non-zero multiple of the AES block size (16 bytes).
// =============================================================================
test("treats workspace-ciphertext-shaped values as already-encrypted", () => {
const ciphertextShaped = [
"MpYeXnSBBF7dzI6K8J89xQ==", // real magic_crypt output: 16 bytes
Buffer.alloc(16, 7).toString("base64"), // 16 bytes
Buffer.alloc(32, 7).toString("base64"), // 32 bytes
"$vault:f/x/cfg",
"$aws_sm:f/x/cfg",
"$azure_kv:f/x/cfg",
];
for (const value of ciphertextShaped) {
expect(looksLikeWorkspaceCiphertext(value)).toBe(true);
}
});
test("treats hand-authored plaintext as NOT already-encrypted", () => {
const plaintext = [
"some: plaintext\n", // space, colon, newline
"original-secret", // hyphen, not length % 4
"hunter2",
'{"a": 1}',
"", // empty
"dGVzdA==", // valid base64 but decodes to 4 bytes (not % 16)
Buffer.alloc(17, 7).toString("base64"), // 17 bytes (not % 16)
"$omething-plain", // starts with $ but is not a real backend marker
];
for (const value of plaintext) {
expect(looksLikeWorkspaceCiphertext(value)).toBe(false);
}
});
+97
View File
@@ -219,6 +219,103 @@ describe("variable", () => {
});
});
test("push encrypts a plaintext secret value (no --plain-secrets) and round-trips", async () => {
await withTestBackend(async (backend, tempDir) => {
await setupWorkspaceProfile(backend);
const uniqueId = Date.now();
const varPath = `f/test/sec_push_${uniqueId}`;
// Existing secret variable (server-encrypted).
const createResp = await backend.apiRequest!(
`/api/w/${backend.workspace}/variables/create`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
path: varPath,
value: "original-secret",
is_secret: true,
description: "",
}),
}
);
expect(createResp.status).toBeLessThan(300);
await createResp.text();
// A hand-authored spec file: plaintext value, is_secret: true. Pushing it
// without --plain-secrets must encrypt the value server-side, not store the
// plaintext verbatim as ciphertext (which would make every read fail).
const specPath = join(tempDir, "v.yaml");
await writeFile(
specPath,
`value: |\n some: plaintext\nis_secret: true\ndescription: ""\n`,
"utf-8"
);
const pushResult = await backend.runCLICommand(
["variable", "push", specPath, varPath],
tempDir
);
expect(pushResult.code).toEqual(0);
// The value must decrypt cleanly to the pushed plaintext.
const apiResp = await backend.apiRequest!(
`/api/w/${backend.workspace}/variables/get/${varPath}?decrypt_secret=true`
);
expect(apiResp.status).toEqual(200);
const varData = await apiResp.json();
expect(varData.is_secret).toBe(true);
expect(varData.value).toBe("some: plaintext\n");
});
});
test("push flips is_secret from true to false", async () => {
await withTestBackend(async (backend, tempDir) => {
await setupWorkspaceProfile(backend);
const uniqueId = Date.now();
const varPath = `f/test/sec_down_${uniqueId}`;
const createResp = await backend.apiRequest!(
`/api/w/${backend.workspace}/variables/create`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
path: varPath,
value: "original-secret",
is_secret: true,
description: "",
}),
}
);
expect(createResp.status).toBeLessThan(300);
await createResp.text();
const specPath = join(tempDir, "v_down.yaml");
await writeFile(
specPath,
`value: "now-public"\nis_secret: false\ndescription: ""\n`,
"utf-8"
);
const pushResult = await backend.runCLICommand(
["variable", "push", specPath, varPath],
tempDir
);
expect(pushResult.code).toEqual(0);
const apiResp = await backend.apiRequest!(
`/api/w/${backend.workspace}/variables/get/${varPath}?decrypt_secret=true`
);
expect(apiResp.status).toEqual(200);
const varData = await apiResp.json();
expect(varData.is_secret).toBe(false);
expect(varData.value).toBe("now-public");
});
});
test("pull retrieves variables into local files", async () => {
await withTestBackend(async (backend, tempDir) => {
await setupWorkspaceProfile(backend);
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "@windmill-labs/components",
"version": "1.735.0",
"version": "1.737.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@windmill-labs/components",
"version": "1.735.0",
"version": "1.737.0",
"hasInstallScript": true,
"license": "AGPL-3.0",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@windmill-labs/components",
"version": "1.735.0",
"version": "1.737.0",
"scripts": {
"dev": "vite dev",
"dev:ui-builder": "mv static/ui_builder static/ui_builder.dev-disabled 2>/dev/null || true ; trap 'mv static/ui_builder.dev-disabled static/ui_builder 2>/dev/null || true' EXIT ; vite dev",
+102 -2
View File
@@ -1,4 +1,4 @@
<!DOCTYPE html>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
@@ -23,6 +23,106 @@
}
</style>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<script>
// WIN-2006: when the app is rendered inside an opaque-origin (sandboxed,
// no allow-same-origin) iframe, Web Storage access throws a SecurityError.
// Install an in-memory shim before the SvelteKit app boots so the app (and
// Windmill SPA code running inside the frame) keeps working within the
// session. No-op in normal real-origin contexts, so zero blast radius.
;(function () {
// `onOp` (localStorage only) relays each mutation up to the embedder,
// which backs a single store shared across all apps. sessionStorage
// stays in-memory/session-only.
function shim(onOp) {
var m = {}
return {
__hydrate: function (obj) {
if (obj) for (var k in obj) m['' + k] = '' + obj[k]
},
getItem: function (k) {
k = '' + k
return Object.prototype.hasOwnProperty.call(m, k) ? m[k] : null
},
setItem: function (k, v) {
m['' + k] = '' + v
if (onOp) onOp({ op: 'set', key: '' + k, value: '' + v })
},
removeItem: function (k) {
delete m['' + k]
if (onOp) onOp({ op: 'remove', key: '' + k })
},
clear: function () {
for (var k in m) delete m[k]
if (onOp) onOp({ op: 'clear' })
},
key: function (i) {
var ks = Object.keys(m)
return i < ks.length ? ks[i] : null
},
get length() {
return Object.keys(m).length
}
}
}
try {
window.localStorage.getItem('__wm_probe__')
} catch (e) {
var framed = window.parent !== window
function relayOp(o) {
if (framed)
try {
window.parent.postMessage(
{ type: 'wm_ls_op', op: o.op, key: o.key, value: o.value },
'*'
)
} catch (_) {}
}
var ls = shim(framed ? relayOp : null)
try {
Object.defineProperty(window, 'localStorage', { value: ls, configurable: true })
} catch (_) {}
try {
Object.defineProperty(window, 'sessionStorage', {
value: shim(null),
configurable: true
})
} catch (_) {}
// Persistence: ask the embedder for the shared store and hydrate the
// in-memory localStorage when it arrives.
if (framed) {
window.addEventListener('message', function (ev) {
if (ev.source === window.parent && ev.data && ev.data.type === 'wm_ls_hydrate') {
ls.__hydrate(ev.data.data)
}
})
try {
window.parent.postMessage({ type: 'wm_ls_req' }, '*')
} catch (_) {}
}
// `document.cookie` also throws in an opaque origin; back it with an
// in-memory jar so reads don't crash apps. This is NOT the real
// session cookie (unreachable here) — just an isolated client-side store.
try {
var jar = {}
Object.defineProperty(Document.prototype, 'cookie', {
configurable: true,
get: function () {
return Object.keys(jar)
.map(function (k) {
return k + '=' + jar[k]
})
.join('; ')
},
set: function (v) {
var p = String(v).split(';')[0]
var i = p.indexOf('=')
if (i > -1) jar[p.slice(0, i).trim()] = p.slice(i + 1).trim()
}
})
} catch (_) {}
}
})()
</script>
%sveltekit.head%
</head>
<body data-sveltekit-preload-code="viewport" class="outline-none focus:outline-none">
@@ -56,7 +156,7 @@
/>
<polygon
class="st3"
points="136.93,132.47 116.46,167.93 73.82,241.78 130.71,241.78 144.9,217.2 180.13,156.18 193.82,132.46
points="136.93,132.47 116.46,167.93 73.82,241.78 130.71,241.78 144.9,217.2 180.13,156.18 193.82,132.46
"
/>
<polygon
+22
View File
@@ -368,6 +368,15 @@
divEl?.classList.add('hidden')
}
// Mirrors the value Monaco's model currently holds, as last synced through
// `code`. The external-`code`→model effect below reflects only when `code`
// diverges from this sentinel, i.e. when `code` was set by an outside writer
// (draft load, template reset) rather than echoed back from the model. Writes
// that go straight to the model (AI chat apply, collab) update `code` via the
// debounced change handler, which keeps this sentinel in lockstep — so the
// effect never reflects a stale `code` over a model that moved ahead.
let lastReflectedCode = code
export function setCode(ncode: string, noHistory: boolean = false): void {
// Track whether the code actually changed before updating.
const changed = code != ncode
@@ -392,6 +401,9 @@
editor.pushUndoStop()
}
}
// The model now holds `ncode`; record it so the reflect effect treats this
// as already-synced and doesn't write it back.
lastReflectedCode = ncode
// Dispatch change immediately when code actually changed. This ensures
// callers like the Reset button and copilot trigger on:change handlers.
// The debounced onDidChangeModelContent handler will no-op since code
@@ -425,6 +437,10 @@
return
}
code = ncode
// `code` was just echoed from the model, so keep the sentinel aligned —
// this is what prevents the reflect effect from racing the model during a
// burst of in-editor edits (e.g. an AI chat apply).
lastReflectedCode = ncode
dispatch('change', ncode)
}
@@ -2020,6 +2036,12 @@
const next = code ?? ''
const ed = editor
if (!ed) return
// Only reflect genuine external `code` changes. When `code` merely echoed a
// model edit (typing, AI chat apply, collab), `lastReflectedCode` already
// matches and we skip — otherwise a debounced echo could overwrite a model
// that has since moved further ahead, reverting the newer edit.
if (code === lastReflectedCode) return
lastReflectedCode = code
untrack(() => {
if (ed.getValue() === next) return
const model = ed.getModel()
@@ -13,7 +13,7 @@
import { twMerge } from 'tailwind-merge'
import type { Output } from '../../rx'
import ResolveNavbarItemPath from './ResolveNavbarItemPath.svelte'
import { urlParamsToObject } from '$lib/utils'
import { urlParamsToObject, WINDMILL_RESERVED_QUERY_PARAMS } from '$lib/utils'
interface Props {
navbarItem: NavbarItem
@@ -54,8 +54,22 @@
let resolvedHidden: boolean | undefined = $state(undefined)
function extractPathDetails() {
const url = window.location.pathname + window.location.search + window.location.hash
const processedUrl = url.replace('/apps/edit/', '').replace('/apps/get/', '')
// Drop Windmill transport params (wm_embed, …) so they don't poison the
// comparison against the item's resolved path.
const params = new URLSearchParams(window.location.search)
const reserved: string[] = []
params.forEach((_v, k) => {
if (WINDMILL_RESERVED_QUERY_PARAMS.has(k)) reserved.push(k)
})
reserved.forEach((k) => params.delete(k))
const qs = params.toString()
const url = window.location.pathname + (qs ? `?${qs}` : '') + window.location.hash
// `/app_embed/{workspace}/` is the opaque in-workspace viewer route
// (WIN-2006) — same app-path suffix as `/apps/get/`.
const processedUrl = url
.replace('/apps/edit/', '')
.replace('/apps/get/', '')
.replace(/^\/app_embed\/[^/]+\//, '')
return processedUrl
}
@@ -86,7 +86,10 @@
}
const resolvedConfig = $state(
initConfig(components['dbexplorercomponent'].initialData.configuration, untrack(() => configuration))
initConfig(
components['dbexplorercomponent'].initialData.configuration,
untrack(() => configuration)
)
)
let timeoutInput: number | undefined = undefined
@@ -180,18 +183,22 @@
)
}
let outputs = initOutput($worldStore, untrack(() => id), {
selectedRowIndex: 0,
selectedRow: {},
selectedRows: [] as any[],
result: [] as any[],
inputs: {},
loading: false,
page: 0,
newChange: { row: 0, column: '', value: undefined },
ready: undefined as boolean | undefined,
openedModalRow: {}
})
let outputs = initOutput(
$worldStore,
untrack(() => id),
{
selectedRowIndex: 0,
selectedRow: {},
selectedRows: [] as any[],
result: [] as any[],
inputs: {},
loading: false,
page: 0,
newChange: { row: 0, column: '', value: undefined },
ready: undefined as boolean | undefined,
openedModalRow: {}
}
)
let lastResource: string | undefined = undefined
@@ -260,9 +267,7 @@
resolvedConfig.type,
{
table: {
selectOptions: dbSchemas
? await getTablesByResource(dbSchemas, dbtype, dbPath, $workspaceStore!)
: [],
selectOptions: dbSchemas ? await getTablesByResource(dbSchemas, dbtype) : [],
loading: false
}
}
@@ -1,4 +1,4 @@
import { JobService, ResourceService } from '$lib/gen'
import { JobService } from '$lib/gen'
import { runScriptAndPollResult } from '$lib/components/jobs/utils'
import type { DbInput } from '$lib/components/dbTypes'
@@ -39,17 +39,9 @@ export async function loadTableMetaData(
const ducklake = input.type === 'ducklake' ? input.ducklake : undefined
const dbArg = getDatabaseArg(input)
// MySQL needs the database name for metadata queries
let databaseName: string | undefined
if (input.type === 'database' && input.resourceType === 'mysql') {
const resourceObj = (await ResourceService.getResourceValue({
workspace,
path: input.resourcePath
})) as any
databaseName = resourceObj?.database
}
const content = makeMetadataMarker('LOAD_TABLE_METADATA', { table, databaseName }, ducklake)
// MySQL: the metadata query resolves the database name server-side (it falls
// back to `DATABASE()`), so we don't read the resource value client-side for it.
const content = makeMetadataMarker('LOAD_TABLE_METADATA', { table }, ducklake)
const job = await JobService.runScriptPreview({
workspace,
@@ -106,22 +98,10 @@ export async function loadAllTablesMetaData(
const dbArg = getDatabaseArg(input)
const ducklake = input.type === 'ducklake' ? input.ducklake : undefined
// MySQL needs the database name for metadata queries
let databaseName: string | undefined
if (input.type === 'database' && input.resourceType === 'mysql') {
const resourceObj = (await ResourceService.getResourceValue({
workspace,
path: input.resourcePath
})) as any
databaseName = resourceObj?.database
}
const language = getLanguageByResourceType(dbType)
const content = makeMetadataMarker(
'LOAD_TABLE_METADATA',
{ table: undefined, databaseName },
ducklake
)
// MySQL db name is resolved server-side via `DATABASE()` (see loadTableMetaData);
// no client-side resource-value read.
const content = makeMetadataMarker('LOAD_TABLE_METADATA', { table: undefined }, ducklake)
let result = (await runScriptAndPollResult({
workspace,
@@ -259,7 +239,11 @@ export async function getDbSchemas(
const dbSchema = {
lang: resourceTypeToLang(resourceType) as SQLSchema['lang'],
schema,
publicOnly: !!schema.public || !!schema.PUBLIC || !!schema.dbo
publicOnly: !!schema.public || !!schema.PUBLIC || !!schema.dbo,
// MySQL introspection selects `DATABASE() AS default_db_name`; carry it
// so the table picker can tell the default db apart from other visible
// schemas. Other dbs don't return it (stays undefined).
defaultDb: Array.isArray(result) ? (result[0] as any)?.default_db_name : undefined
}
return { ...dbSchema, stringified: stringifySchema(dbSchema) }
} else {
@@ -283,9 +267,7 @@ export async function getDbSchemas(
export async function getTablesByResource(
schema: Partial<Record<string, DBSchema>>,
dbType: DbType | undefined,
dbPath: string,
workspace: string
dbType: DbType | undefined
): Promise<string[]> {
const s = Object.values(schema)?.[0]
switch (dbType) {
@@ -301,14 +283,15 @@ export async function getTablesByResource(
return paths
}
case 'mysql': {
const resourceObj = (await ResourceService.getResourceValue({
workspace,
path: dbPath.split('$res:')[1]
})) as any
// MySQL introspection lists DATABASE() plus any other visible non-system
// schemas. Show the default db's tables unprefixed and the rest as
// `db.table` — matching the pre-removal behavior (which matched the
// resource's `database`); `defaultDb` is the connection's DATABASE().
const defaultDb = s && 'defaultDb' in s ? s.defaultDb : undefined
const paths: string[] = []
for (const key in s?.schema) {
for (const subKey in s.schema[key]) {
if (key === resourceObj?.database) {
if (key === defaultDb) {
paths.push(`${subKey}`)
} else {
paths.push(`${key}.${subKey}`)
@@ -3,7 +3,7 @@
import type { AppInput } from '../../inputType'
import type { Output } from '../../rx'
import type { AppViewerContext, ListContext } from '../../types'
import { isScriptByNameDefined, isScriptByPathDefined } from '../../utils'
import { appNavigateSameWindow, isScriptByNameDefined, isScriptByPathDefined } from '../../utils'
import NonRunnableComponent from './NonRunnableComponent.svelte'
import RunnableComponent from './RunnableComponent.svelte'
import { sendUserToast } from '$lib/toast'
@@ -261,7 +261,9 @@
if (newTab) {
window.open(gotoUrl, '_blank')
} else {
window.location.href = gotoUrl
// Top-level load; inside the opaque viewer iframe this targets the
// top page (pre-sandbox behavior) instead of the cookieless frame.
appNavigateSameWindow(gotoUrl)
}
break
@@ -2,6 +2,8 @@ import type { World } from '../../rx'
import { sendUserToast } from '$lib/toast'
import { waitJob } from '$lib/components/waitJob'
import { base } from '$lib/base'
import { appNavigateSameWindow } from '../../utils'
import { OpenAPI } from '$lib/gen/core/OpenAPI'
export function computeGlobalContext(
world: World | undefined,
@@ -200,7 +202,9 @@ export async function eval_like(
}
window.open(x, '_blank')
} else {
window.location.href = x
// Top-level load; inside the opaque viewer iframe this targets the
// top page (pre-sandbox behavior) instead of the cookieless frame.
appNavigateSameWindow(x)
}
},
(id, index) => {
@@ -292,10 +296,30 @@ export async function eval_like(
if (typeof input === 'object' && input.s3) {
const workspaceId = ((context ?? {}) as any).ctx?.workspace
const s3href = `${base}/api/w/${workspaceId}/job_helpers/download_s3_file?file_key=${encodeURIComponent(
input?.s3 ?? ''
)}${input?.storage ? `&storage=${input.storage}` : ''}`
downloadFile(s3href, filename || input.s3)
const appPath = ((context ?? {}) as any).ctx?.app_path
let inSandbox = false
try {
inSandbox =
window.parent !== window &&
new URLSearchParams(window.location.search).get('wm_embed') === '1'
} catch (_) {}
if (inSandbox && appPath && typeof OpenAPI.TOKEN === 'string' && OpenAPI.TOKEN) {
// Sandboxed viewer: the opaque iframe carries no cookie, so the
// cookie-authed job_helpers download fails. Route through the
// app-policy-confined apps_u endpoint with the embed token in the
// query (like the image/file components), scoped to this app's path.
const params = new URLSearchParams()
params.append('s3', input.s3 ?? '')
if (input.storage) params.append('storage', input.storage)
params.append('token', OpenAPI.TOKEN)
const s3href = `${base}/api/w/${workspaceId}/apps_u/download_s3_file/${appPath}?${params.toString()}`
downloadFile(s3href, filename || input.s3)
} else {
const s3href = `${base}/api/w/${workspaceId}/job_helpers/download_s3_file?file_key=${encodeURIComponent(
input?.s3 ?? ''
)}${input?.storage ? `&storage=${input.storage}` : ''}`
downloadFile(s3href, filename || input.s3)
}
} else if (typeof input === 'string') {
if (input.startsWith('data:')) {
downloadFile(input, filename)
@@ -395,14 +395,16 @@
}
}
async function setPublishState() {
async function setPublishState(message?: string) {
policy = await updatePolicy($app, policy)
await AppService.updateApp({
workspace: $workspaceStore!,
path: $appPath,
requestBody: { policy }
})
if (policy.execution_mode == 'anonymous') {
if (message) {
sendUserToast(message)
} else if (policy.execution_mode == 'anonymous') {
sendUserToast('App require no login to be accessed')
} else {
sendUserToast('App require login and read-access')
@@ -1,5 +1,6 @@
<script lang="ts">
import { Alert } from '$lib/components/common'
import Badge from '$lib/components/common/badge/Badge.svelte'
import Toggle from '$lib/components/Toggle.svelte'
import { enterpriseLicense, userStore, workspaceStore } from '$lib/stores'
import { Loader2 } from 'lucide-svelte'
@@ -41,7 +42,7 @@
newApp = false
}: {
policy: any
setPublishState: () => void
setPublishState: (message?: string) => void
appPath: string
customPath: string | undefined
onLatest: boolean
@@ -274,6 +275,40 @@
<div class="mt-10"></div>
<div class="flex items-center gap-2">
<h2>Sandbox isolation</h2>
<Badge color="yellow">Alpha</Badge>
</div>
<div class="my-6">
<Toggle
options={{ right: "Isolate the app from the viewer's browser session" }}
checked={policy.sandbox == true}
on:change={(e) => {
policy.sandbox = e.detail || undefined
setPublishState(e.detail ? 'Sandbox isolation enabled' : 'Sandbox isolation disabled')
}}
disabled={!savedApp}
/>
<div class="text-xs text-secondary mt-1">
Controls what the app's browser-side code can reach in each viewer's browser — distinct from the
on-behalf-of model above (which sets who its runnables run as). Off by default, the app's code
uses the viewer's own session; enable it to confine the app to a narrowly-scoped token instead,
on every surface (public URL and in-workspace). Leave it off if the app needs full browser
features (IndexedDB, third-party auth/SDKs, OAuth redirects).
</div>
{#if !savedApp}
<div class="text-xs text-tertiary mt-1">Save the app once to change this setting.</div>
{/if}
{#if policy.sandbox == true}
<div class="mt-2">
<Alert type="warning" title="Alpha feature" size="xs">
Sandbox isolation is in alpha. After enabling, open the app from its public URL to confirm
it still works, and report any broken behavior.
</Alert>
</div>
{/if}
</div>
{#if !hideSecretUrl}
<h2>Public URL</h2>
@@ -78,7 +78,8 @@
workspace: untrack(() => workspace),
mode: 'viewer',
summary: untrack(() => summary),
author: untrack(() => policy).on_behalf_of_email
author: untrack(() => policy).on_behalf_of_email,
app_path: untrack(() => appPath)
}
function resizeWindow() {
@@ -0,0 +1,146 @@
<script lang="ts">
/*
* WIN-2006: shared in-workspace app viewer (low-code AND raw). Renders the app
* through the same PublicAppFrame -> PublicApp machinery the public viewer uses,
* so the sandbox behavior is identical on every page. PublicAppFrame picks the
* rendering: an opaque /app_embed iframe for sandboxed low-code, or inline (with
* the bundle isolated in RawAppPreview's own opaque iframe) for raw and for
* unsandboxed apps. When the publisher opts an app into sandbox isolation, its
* untrusted markup/JS must not run with the member's full session — hence the
* scoped embed token / opaque isolation.
*/
import { base } from '$lib/base'
import PublicApp from '$lib/components/apps/editor/PublicApp.svelte'
import PublicAppFrame from '$lib/components/apps/editor/PublicAppFrame.svelte'
import { Button } from '$lib/components/common'
import { AppService, OpenAPI } from '$lib/gen'
import { userStore } from '$lib/stores'
import { canWrite } from '$lib/utils'
import { getUserExt } from '$lib/user'
import { Pen } from 'lucide-svelte'
import { page } from '$app/state'
let {
workspace,
path,
editHref
}: {
workspace: string
path: string
/** Where the Edit button points (low-code vs raw editor). */
editHref: string
} = $props()
let app: any = $state(undefined)
let notExists = $state(false)
let noPermission = $state(false)
let canWriteApp = $state(false)
let refresh: (() => void) | undefined
// The opaque iframe loads the dedicated cookieless, chrome-less viewer route.
// The page's query/hash are forwarded so the app sees the same `ctx.query` /
// `ctx.hash` as the pre-sandbox viewer did. Captured ONCE (not reactively):
// the embedder later mirrors the app's own hash/query changes back onto this
// page's URL (wm_embed_hash relay), and re-deriving the iframe src from them
// would reload the app on its every navigation.
const initialSearchHash = page.url.search + page.url.hash
let viewerUrl = $derived(`${base}/app_embed/${workspace}/${path}${initialSearchHash}`)
const hideEditBtn = page.url.searchParams.get('hideEditBtn') === 'true'
const hideRefreshBar = page.url.searchParams.get('hideRefreshBar') === 'true'
// Embedder side: mint a scoped embed token (by path) from the member's session.
async function fetchEmbedToken(): Promise<{ token?: string }> {
const headers: Record<string, string> = {}
if (typeof OpenAPI.TOKEN === 'string' && OpenAPI.TOKEN) {
headers['Authorization'] = `Bearer ${OpenAPI.TOKEN}`
}
const res = await fetch(`${OpenAPI.BASE}/w/${workspace}/apps/embed_token/p/${path}`, {
headers
})
if (!res.ok) {
const err: any = new Error('Failed to fetch embed token')
err.status = res.status
throw err
}
return await res.json()
}
// Viewer side — used for the inline renderings (raw, and unsandboxed low-code);
// the sandboxed low-code case loads inside the opaque /app_embed iframe instead.
// getAppByPath returns bundle_secret + runnables for raw apps, which
// PublicApp -> RawAppPreview needs.
async function loadApp() {
try {
userStore.set(await getUserExt(workspace))
} catch (e) {
console.warn('Anonymous user')
}
try {
const loaded: any = await AppService.getAppByPath({ workspace, path })
// Raw apps need the bundle secret to load their bundle. getAppByPath
// doesn't compute it (unlike the public handlers), so fetch it here — the
// same call the previous raw viewer used — and hand it to PublicApp ->
// RawAppPreview via bundle_secret.
if (loaded?.raw_app && !loaded.bundle_secret) {
try {
loaded.bundle_secret = await AppService.getPublicSecretOfLatestVersionOfApp({
workspace,
path
})
} catch (e) {
console.error('Failed to load raw app bundle secret', e)
}
}
app = loaded
noPermission = false
notExists = false
} catch (e: any) {
if (e.status == 401) refresh?.()
else if (e.status == 403) noPermission = true
else notExists = true
}
}
// Edit button: determine write access on this real-origin page (cookie).
async function loadPerms() {
try {
const lite: any = await AppService.getAppLiteByPath({ workspace, path })
canWriteApp = canWrite(lite?.path, lite?.extra_perms ?? {}, $userStore)
} catch (_) {
canWriteApp = false
}
}
$effect(() => {
if (workspace && path) loadPerms()
})
</script>
<PublicAppFrame
{fetchEmbedToken}
{viewerUrl}
onViewerReady={(_token, requestTokenRefresh) => {
refresh = requestTokenRefresh
loadApp()
}}
>
{#snippet viewer()}
<PublicApp
{app}
{workspace}
{notExists}
{noPermission}
jwtError={false}
inWorkspace
{hideRefreshBar}
onLoginSuccess={() => loadApp()}
></PublicApp>
{/snippet}
</PublicAppFrame>
{#if canWriteApp && !hideEditBtn}
<div id="app-edit-btn" class="absolute bottom-4 z-50 right-4">
<Button size="sm" startIcon={{ icon: Pen }} variant="subtle" href={editHref}>Edit</Button>
</div>
{/if}
@@ -7,8 +7,13 @@
import { isCloudHosted } from '$lib/cloud'
import { Alert, Skeleton } from '$lib/components/common'
import { WindmillIcon } from '$lib/components/icons'
import { onMount, setContext } from 'svelte'
import { IS_APP_PUBLIC_CONTEXT_KEY, type EditorBreakpoint } from '../types'
import { getContext, onMount, setContext } from 'svelte'
import {
EMBED_NAV_CONTEXT_KEY,
IS_APP_PUBLIC_CONTEXT_KEY,
type EditorBreakpoint,
type EmbedNav
} from '../types'
import { UserService, type AppWithLastVersion, type GlobalWhoamiResponse } from '$lib/gen'
import { urlParamsToObject } from '$lib/utils'
import { goto } from '$app/navigation'
@@ -24,7 +29,9 @@
jwtError,
onLoginSuccess,
app,
workspace
workspace,
inWorkspace = false,
hideRefreshBar = false
}: {
notExists: boolean
noPermission: boolean
@@ -32,12 +39,27 @@
onLoginSuccess: () => void
app: (AppWithLastVersion & { value: any; workspace_id?: string }) | undefined
workspace: string | undefined
/**
* In-workspace rendering (`/apps/get`, `/app_embed`): keep exact parity
* with the pre-sandbox member viewer — no "Powered by Windmill" badge, no
* user overlay, no HTML-result approval gate, column flex wrapper.
*/
inWorkspace?: boolean
hideRefreshBar?: boolean
} = $props()
// Use workspace from props or from app.workspace_id (for custom path responses)
let effectiveWorkspace = $derived(workspace ?? app?.workspace_id)
setContext(IS_APP_PUBLIC_CONTEXT_KEY, true)
// HTML results from runnables only need viewer approval on the public
// surfaces (untrusted distribution); the in-workspace viewer never gated them.
setContext(IS_APP_PUBLIC_CONTEXT_KEY, !inWorkspace)
// WIN-2006: inside the opaque viewer iframe, navigations to other routes
// (navbar "app" items) must happen on the TOP page — the iframe is cookieless,
// so navigating it would just show a login screen. PublicAppFrame provides the
// relay; outside the opaque viewer this is undefined and goto works directly.
const embedNav = getContext<EmbedNav | undefined>(EMBED_NAV_CONTEXT_KEY)
const breakpoint = writable<EditorBreakpoint>('lg')
@@ -71,27 +93,29 @@
})
</script>
<div
class="z-50 text-xs fixed bottom-1 right-2 {$enterpriseLicense && !isCloudHosted()
? 'transition-opacity delay-1000 duration-1000 opacity-20 hover:delay-0 hover:opacity-100'
: ''}"
>
<a href="https://windmill.dev" class="whitespace-nowrap text-primary inline-flex items-center"
>Powered by &nbsp;<WindmillIcon />&nbsp;Windmill</a
{#if !inWorkspace}
<div
class="z-50 text-xs fixed bottom-1 right-2 {$enterpriseLicense && !isCloudHosted()
? 'transition-opacity delay-1000 duration-1000 opacity-20 hover:delay-0 hover:opacity-100'
: ''}"
>
</div>
<a href="https://windmill.dev" class="whitespace-nowrap text-primary inline-flex items-center"
>Powered by &nbsp;<WindmillIcon />&nbsp;Windmill</a
>
</div>
{#snippet userInfo(child)}
<div class="flex gap-1 items-center"><User size={14} />{child}</div>
{/snippet}
{#snippet userInfo(child)}
<div class="flex gap-1 items-center"><User size={14} />{child}</div>
{/snippet}
<div class="z-50 text-2xs text-primary absolute top-3 left-2"
>{#if $userStore}
{@render userInfo($userStore.username)}
{:else if globalUser}
{@render userInfo(globalUser.email)}
{:else}<UserRoundX size={14} />{/if}
</div>
<div class="z-50 text-2xs text-primary absolute top-3 left-2"
>{#if $userStore}
{@render userInfo($userStore.username)}
{:else if globalUser}
{@render userInfo(globalUser.email)}
{:else}<UserRoundX size={14} />{/if}
</div>
{/if}
{#if notExists}
<div class="px-4 mt-20"
@@ -132,7 +156,11 @@
{:else}
<div
class={twMerge(
'min-h-screen h-full w-full flex',
// `flex-col` matches the pre-sandbox in-workspace viewer exactly;
// the public viewer always used a plain `flex` wrapper.
inWorkspace
? 'min-h-screen h-full w-full flex flex-col'
: 'min-h-screen h-full w-full flex',
app?.value?.['css']?.['app']?.['viewer']?.class,
'wm-app-viewer'
)}
@@ -140,6 +168,7 @@
>
<AppPreview
noBackend={false}
{hideRefreshBar}
context={{
email: $userStore?.email,
name: $userStore?.name,
@@ -156,7 +185,7 @@
policy={app.policy}
isEditor={false}
replaceStateFn={(path) => goto(path)}
gotoFn={(path, opt) => goto(path, opt)}
gotoFn={(path, opt) => (embedNav ? embedNav.navigateTop(path) : goto(path, opt))}
/>
</div>
{/if}
@@ -0,0 +1,427 @@
<script lang="ts">
/*
* WIN-2006: published apps render arbitrary user-authored markup/JS. When the
* publisher opts an app into sandbox isolation (alpha), that untrusted code (a
* malicious author, or an XSS bug in the app) must not run with the viewer's
* session. How it's contained depends on the app:
*
* - Low-code, sandboxed: rendered in an **opaque-origin** (sandboxed, no
* `allow-same-origin`) iframe and handed a **narrowly-scoped embed token** —
* never the session cookie. This component is the embedder (top window:
* authenticates the viewer, mints the token, renders the opaque iframe) and,
* inside that iframe (`wm_embed=1`), the viewer (uses the token as its only
* credential, then renders the app).
* - Raw, sandboxed: rendered directly here as a **single** opaque bundle iframe
* (the author bundle is already isolated in its own opaque iframe); no opaque
* viewer / embed token needed.
* - Unsandboxed (default): the app runs same-origin with the viewer's full
* session, the pre-isolation behavior. Rendered directly here.
*
* No `PUBLIC_APP_DOMAIN` is required: the opaque origin is, for same-origin-policy
* purposes, as foreign to the main app as a different domain. The opaque viewer's
* API calls are cross-origin and rely on `Access-Control-Allow-Origin: *` + the
* bearer token (no cookie); the raw wrapper document always carries `CSP: sandbox`.
*/
import { BROWSER } from 'esm-env'
import { OpenAPI } from '$lib/gen'
import { page } from '$app/state'
import { onDestroy, onMount, setContext, type Snippet } from 'svelte'
import { Alert, Skeleton } from '$lib/components/common'
import { base } from '$app/paths'
import { goto } from '$app/navigation'
import Login from '$lib/components/Login.svelte'
import { WINDMILL_RESERVED_QUERY_PARAMS } from '$lib/utils'
import { EMBED_NAV_CONTEXT_KEY, type EmbedNav } from '../types'
type EmbedToken = {
token?: string | null
raw_app?: boolean
sandbox?: boolean
app_path?: string | null
workspace_id?: string | null
}
let {
fetchEmbedToken,
onViewerReady,
viewer,
viewerUrl
}: {
/** Embedder-side: validate access + mint the scoped token. Throws with a
* `.status` of 401 (login required) or 404 (not found). */
fetchEmbedToken: () => Promise<EmbedToken>
/** Viewer-side: fired (once per received token) when the embed token is
* available, before the app renders. Use it to kick off data loading.
* `requestTokenRefresh` asks the embedder for a fresh token on a 401. */
onViewerReady?: (token: string | undefined, requestTokenRefresh: () => void) => void
/** Viewer-side: renders the actual app once the embed token is available. */
viewer: Snippet
/** Embedder-side: override the opaque iframe src (the route that renders the
* viewer). Defaults to the current route + `wm_embed=1` (public routes embed
* themselves). The in-workspace viewer sets this because its embedder route
* (`/apps/get`, auth-gated, with chrome) differs from the cookieless,
* chrome-less viewer route (`/app_embed`). */
viewerUrl?: string
} = $props()
const EMBED_PARAM = 'wm_embed'
const ORIGIN_PARAM = 'wm_embedder_origin'
const framed = BROWSER && window.parent !== window
const isViewer = BROWSER && page.url.searchParams.get(EMBED_PARAM) === '1' && framed
// ----------------------------- viewer mode -----------------------------
let viewerToken: string | undefined = $state(undefined)
let viewerReady = $state(false)
// Set when viewer mode never receives a token (see the orphan timer in onMount):
// the page carries `wm_embed` but isn't actually framed by a Windmill embedder.
let viewerOrphaned = $state(false)
const expectedEmbedderOrigin = BROWSER ? page.url.searchParams.get(ORIGIN_PARAM) : null
// Components that embed the token in a URL (images, PDFs, downloads, SSE) read
// it from the `AuthToken` context. In viewer mode that must be the embed token;
// the getter keeps it in sync once the token arrives. In direct render
// (unsandboxed / raw — the viewer snippet runs on this page, not in the opaque
// iframe) expose the page's own bearer credential when there is one: JWT public
// URLs put it in `OpenAPI.TOKEN` (set before the app loads) and have no cookie
// to fall back on. Cookie sessions have no bearer here and keep using the cookie.
setContext<{ token?: string }>('AuthToken', {
get token() {
if (isViewer) return viewerToken
return typeof OpenAPI.TOKEN === 'string' && OpenAPI.TOKEN ? OpenAPI.TOKEN : undefined
}
})
function handleViewerMessage(e: MessageEvent) {
// The embedder has a real origin, so we can validate both the source and
// the origin of messages we receive.
if (e.source !== window.parent) return
if (expectedEmbedderOrigin && e.origin !== expectedEmbedderOrigin) return
if (e.data?.type === 'wm_embed_token') {
const token = e.data.token ?? undefined
viewerToken = token
// The bearer token (when present) is the credential the app uses; no
// cookie reaches this opaque origin.
OpenAPI.TOKEN = token
viewerReady = true
viewerOrphaned = false
onViewerReady?.(token, requestTokenRefresh)
}
}
/** Passed to the viewer: when the rendered app gets a 401 (e.g. the embed
* token expired) it calls this to ask the embedder for a fresh token. */
function requestTokenRefresh() {
if (!isViewer) {
// Embedder-side direct render (raw app, or unsandboxed app): there is no
// opaque viewer to message — just re-run the access check / app load.
initEmbedder()
return
}
viewerReady = false
window.parent.postMessage({ type: 'wm_embed_unauthorized' }, expectedEmbedderOrigin ?? '*')
}
// WIN-2006: the app runs inside the (opaque) viewer iframe, so its in-app URL
// changes never reach the top address bar — breaking shareable deep links. Relay
// the hash + query up to the embedder, which mirrors them onto its own URL
// (e.g. the navbar component's same-app links set ?query and #hash). Never the
// path: the embedder keeps its own pathname, so a hostile app can't rewrite the
// address bar to an unrelated route. Transport params (wm_embed, …) are
// stripped before relaying.
let origPushState: typeof history.pushState | undefined
let origReplaceState: typeof history.replaceState | undefined
function relayHash() {
try {
const params = new URLSearchParams(window.location.search)
const reserved: string[] = []
params.forEach((_v, k) => {
if (WINDMILL_RESERVED_QUERY_PARAMS.has(k)) reserved.push(k)
})
reserved.forEach((k) => params.delete(k))
const qs = params.toString()
const search = qs ? `?${qs}` : ''
window.parent.postMessage(
{ type: 'wm_embed_hash', hash: window.location.hash, search },
expectedEmbedderOrigin ?? '*'
)
} catch (_) {}
}
// Top-level navigation relay (WIN-2006): in-app links to other apps (navbar
// "app" items) must navigate the top page — navigating inside the opaque
// iframe would load the SPA cookieless. Consumed by PublicApp via gotoFn.
setContext<EmbedNav | undefined>(
EMBED_NAV_CONTEXT_KEY,
isViewer
? {
navigateTop: (href: string) => {
try {
window.parent.postMessage(
{ type: 'wm_embed_navigate', href },
expectedEmbedderOrigin ?? '*'
)
} catch (_) {}
}
}
: undefined
)
function installHashRelay() {
origPushState = history.pushState
origReplaceState = history.replaceState
history.pushState = function (data, unused, url) {
origPushState?.call(history, data, unused, url ?? null)
relayHash()
}
history.replaceState = function (data, unused, url) {
origReplaceState?.call(history, data, unused, url ?? null)
relayHash()
}
window.addEventListener('hashchange', relayHash)
window.addEventListener('popstate', relayHash)
}
function uninstallHashRelay() {
if (origPushState) history.pushState = origPushState
if (origReplaceState) history.replaceState = origReplaceState
window.removeEventListener('hashchange', relayHash)
window.removeEventListener('popstate', relayHash)
}
// ---------------------------- embedder mode ----------------------------
let status: 'loading' | 'ready' | 'noPermission' | 'notExists' = $state('loading')
let embedToken: string | null = $state(null)
let iframeEl: HTMLIFrameElement | undefined = $state(undefined)
// WIN-2006: publisher opted this app into sandbox isolation (alpha). When false
// (the default) the app runs same-origin with the viewer's full session — the
// pre-isolation behavior.
let sandboxed = $state(false)
// WIN-2006 Variant A: raw apps render single-iframe. The author's bundle is
// already isolated in its own opaque iframe (served + CSP-sandboxed by the
// backend), so the viewer skips the opaque-viewer indirection and the embed
// token entirely — it renders the app directly on this (real) origin and the
// bridge calls the backend with the page credential (cookie / JWT / anonymous),
// matching the logged-in raw viewer. Low-code keeps the opaque viewer + token.
let isRaw = $state(false)
// WIN-2006: the resolved app path + workspace, used together to scope this app's
// backing localStorage (below) so sandboxed apps don't share one store (even two
// apps at the same path in different workspaces).
let appPath: string | undefined = $state(undefined)
let workspaceId: string | undefined = $state(undefined)
// Same-origin (full session) execution: every app that wasn't opted into
// sandbox isolation. RawAppPreview reads this to drop the bundle's opaque
// sandbox; an unsandboxed low-code app renders directly here.
let unsandboxed = $derived(!sandboxed)
// Read by RawAppPreview (and any app component) to render same-origin (full
// access) instead of the sandboxed bundle iframe.
setContext('IS_APP_UNSANDBOXED', {
get value() {
return unsandboxed
}
})
function buildViewerUrl(): string {
// Default: embed the current route. The in-workspace viewer overrides this
// with a dedicated cookieless, chrome-less viewer route (`/app_embed`).
const url = new URL(viewerUrl ?? window.location.href, window.location.origin)
url.searchParams.set(EMBED_PARAM, '1')
url.searchParams.set(ORIGIN_PARAM, window.location.origin)
// Same origin (no separate domain); the sandbox makes it opaque.
return url.pathname + url.search + url.hash
}
async function initEmbedder() {
status = 'loading'
try {
const resp = await fetchEmbedToken()
embedToken = resp.token ?? null
sandboxed = resp.sandbox ?? false
isRaw = resp.raw_app ?? false
appPath = resp.app_path ?? undefined
workspaceId = resp.workspace_id ?? undefined
status = 'ready'
if (unsandboxed || isRaw) {
// Render the app directly on this origin: same-origin when unsandboxed
// (the default), or a single opaque bundle iframe when it's a sandboxed
// raw app.
onViewerReady?.(undefined, requestTokenRefresh)
} else {
// Sandboxed low-code: hand the scoped token to the opaque viewer iframe.
postTokenToIframe()
}
} catch (e: any) {
status = e?.status === 401 ? 'noPermission' : 'notExists'
}
}
function postTokenToIframe() {
// The iframe is an opaque origin ("null"), which cannot be named as a
// targetOrigin, so we use '*'. This only relaxes the receiver-origin
// check; the message is still delivered solely to our own iframe's
// contentWindow, whose content we control.
iframeEl?.contentWindow?.postMessage({ type: 'wm_embed_token', token: embedToken }, '*')
}
// Persistence authority (WIN-2006): opaque app frames (the viewer SPA) have no
// real Web Storage, so the embedder — on the real origin — backs their
// `localStorage` here. The store is scoped PER APP (keyed by workspace + app path)
// so one sandboxed app cannot read or clobber another's storage. Updated with
// per-key ops so concurrent frames of the same app merge instead of clobbering.
function lsKey(): string {
return `wm_apps_localstorage:${workspaceId ?? ''}:${appPath ?? ''}`
}
function readSharedLs(): Record<string, string> {
try {
return JSON.parse(localStorage.getItem(lsKey()) || '{}')
} catch (_) {
return {}
}
}
function applyLsOp(d: any) {
const s = readSharedLs()
if (d.op === 'set') s[d.key] = String(d.value)
else if (d.op === 'remove') delete s[d.key]
else if (d.op === 'clear') for (const k in s) delete s[k]
try {
localStorage.setItem(lsKey(), JSON.stringify(s))
} catch (_) {}
}
function handleEmbedderMessage(e: MessageEvent) {
// The viewer is opaque-origin (e.origin === 'null'), so we authenticate
// the message by source identity only. Storage messages come from the opaque
// low-code viewer SPA's shim and are backed in this app's per-app store.
if (e.source !== iframeEl?.contentWindow) return
if (e.data?.type === 'wm_embed_ready') {
postTokenToIframe()
} else if (e.data?.type === 'wm_embed_unauthorized') {
initEmbedder()
} else if (e.data?.type === 'wm_ls_req') {
iframeEl?.contentWindow?.postMessage({ type: 'wm_ls_hydrate', data: readSharedLs() }, '*')
} else if (e.data?.type === 'wm_ls_op') {
applyLsOp(e.data)
} else if (e.data?.type === 'wm_embed_hash') {
// Mirror the viewer's in-app hash + query onto our own URL (shareable
// deep links). Keep our pathname (an app can't rewrite the address bar
// to another route) and our own transport params (e.g. wm_coep).
const hash = typeof e.data.hash === 'string' ? e.data.hash : ''
const relayed = new URLSearchParams(
typeof e.data.search === 'string' ? e.data.search : window.location.search
)
for (const k of WINDMILL_RESERVED_QUERY_PARAMS) {
relayed.delete(k)
const own = new URLSearchParams(window.location.search).get(k)
if (own !== null) relayed.set(k, own)
}
const qs = relayed.toString()
const search = qs ? `?${qs}` : ''
if (window.location.hash !== hash || window.location.search !== search) {
history.replaceState(null, '', window.location.pathname + search + hash)
}
} else if (e.data?.type === 'wm_embed_navigate') {
// App-initiated same-window navigation (navbar app items, frontend-script
// `goto`, button `gotoUrl`): this page navigates itself, exactly like when
// the app ran on it pre-sandbox. Same-origin paths use SPA navigation;
// full http(s) URLs do a real load (pre-sandbox apps could already do
// this via window.location, so it grants nothing new). Everything else —
// javascript:, data:, protocol-relative `//host` — is rejected.
const href = typeof e.data.href === 'string' ? e.data.href : ''
if (href.startsWith('/') && !href.startsWith('//')) {
goto(href)
} else if (/^https?:\/\//i.test(href)) {
window.location.href = href
}
}
}
onMount(() => {
if (isViewer) {
window.addEventListener('message', handleViewerMessage)
installHashRelay()
// Announce readiness so the embedder sends us the token.
window.parent.postMessage({ type: 'wm_embed_ready' }, expectedEmbedderOrigin ?? '*')
// This page is only ever loaded as the embedder's opaque iframe, which
// replies with the token within milliseconds. If none arrives, it was
// opened with `wm_embed` outside a Windmill embedder (e.g. its iframe src
// embedded directly in a third-party page); show a diagnostic instead of an
// indefinite skeleton.
const orphanTimer = setTimeout(() => {
if (!viewerReady) viewerOrphaned = true
}, 3000)
return () => clearTimeout(orphanTimer)
} else {
window.addEventListener('message', handleEmbedderMessage)
initEmbedder()
}
})
onDestroy(() => {
if (!BROWSER) return
window.removeEventListener('message', handleViewerMessage)
window.removeEventListener('message', handleEmbedderMessage)
if (isViewer) uninstallHashRelay()
})
</script>
{#if isViewer}
{#if viewerReady}
{@render viewer()}
{:else if viewerOrphaned}
<div class="px-4 mt-20 max-w-xl mx-auto">
<Alert type="info" title="Open this app from Windmill">
This is a Windmill app viewer and must be loaded by Windmill. If you embedded it in your own
page, use the app's public URL without the <code>wm_embed</code> parameter.
</Alert>
</div>
{:else}
<Skeleton layout={[[4], 0.5, [50]]} />
{/if}
{:else if status === 'loading'}
<Skeleton layout={[[4], 0.5, [50]]} />
{:else if status === 'notExists'}
<div class="px-4 mt-20">
<Alert type="error" title="Not found">
There was an error loading the app, is the url correct?
<a href={base}>Go to Windmill</a>
</Alert>
</div>
{:else if status === 'noPermission'}
<!-- Login happens here, on the embedder (main) window, so the session cookie
is set on the main origin only and never reaches the opaque iframe. -->
<div class="px-4 mt-20 w-full text-center font-bold text-xl">This app requires read access</div>
<div class="px-2 mx-auto mt-20 max-w-xl w-full">
<Login
onLoginSuccess={() => initEmbedder()}
popup
rd={page.url.pathname + page.url.search + page.url.hash}
/>
</div>
{:else if unsandboxed}
<!-- Same-origin (full session): the app was not opted into sandbox isolation
(the default). Rendered directly here; RawAppPreview reads
IS_APP_UNSANDBOXED to drop the bundle's opaque sandbox. -->
{@render viewer()}
{:else if isRaw}
<!-- Variant A: sandboxed raw app rendered directly on the real origin. The
untrusted author bundle stays isolated in its own opaque iframe (inside
RawAppPreview); no opaque viewer and no embed token are needed. -->
{@render viewer()}
{:else}
<!-- referrerpolicy: the embedder page URL can carry a viewer credential (the
JWT path segment of share links); without this, the same-origin iframe
navigation would expose it to app-authored code via document.referrer. -->
<iframe
bind:this={iframeEl}
src={buildViewerUrl()}
title="App"
class="w-full h-screen border-0 block"
sandbox="allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox allow-downloads allow-modals allow-top-navigation"
allow="clipboard-read; clipboard-write; fullscreen"
referrerpolicy="no-referrer"
></iframe>
{/if}
@@ -180,12 +180,13 @@ export async function updatePolicy(app: App, currentPolicy: Policy | undefined):
})
.filter(Boolean) as { s3_path: string; storage?: string | undefined }[]
return {
const next = {
...(currentPolicy ?? {}),
allowed_s3_keys: s3FileKeys,
s3_inputs,
triggerables_v2: ntriggerables
}
return next
}
export async function processRunnable(
@@ -370,6 +370,12 @@ export type EditorBreakpoint = 'sm' | 'lg'
export const IS_APP_PUBLIC_CONTEXT_KEY = 'isAppPublicContext' as const
// Set by PublicAppFrame in opaque-viewer mode (WIN-2006). Lets the app relay
// top-level navigations (e.g. navbar links to another app) to the embedder,
// since navigating inside the opaque iframe would load the SPA cookieless.
export const EMBED_NAV_CONTEXT_KEY = 'appEmbedNav' as const
export type EmbedNav = { navigateTop: (href: string) => void }
type ComponentID = string
export type ContextPanelContext = {
+25
View File
@@ -20,6 +20,31 @@ import type {
} from './types'
import { allItems, BG_PREFIX } from './editor/appUtilsCore'
/**
* Same-window navigation for app code (frontend-script `goto`, button
* `onSuccess: gotoUrl`). Inside the opaque viewer iframe (WIN-2006,
* `wm_embed=1`), navigating the current window would load the target inside
* the cookieless frame so the navigation is relayed to the embedder page,
* which navigates itself (`wm_embed_navigate` in PublicAppFrame). That matches
* the pre-sandbox behavior exactly: the app used to run ON the embedder page,
* including when that page is itself inside a third-party iframe (where the
* embedder not the third party's top was what `window.location` changed).
* Outside the opaque viewer it keeps navigating the current window as before.
*/
export function appNavigateSameWindow(url: string) {
try {
const params = new URLSearchParams(window.location.search)
if (window.parent !== window && params.get('wm_embed') === '1') {
window.parent.postMessage(
{ type: 'wm_embed_navigate', href: url },
params.get('wm_embedder_origin') ?? '*'
)
return
}
} catch (_) {}
window.location.href = url
}
// `migrateApp` moved to its own light module so non-editor callers can reuse it
// without pulling the whole `apps/utils` graph; re-exported here for existing
// `from '../utils'` importers.
@@ -33,7 +33,7 @@
</script>
<Row
href="{base}/apps/get_raw/{app.version}/{app.path}"
href="{base}/apps_raw/get/{app.path}"
kind="raw_app"
{marked}
path={app.path}
@@ -78,8 +78,10 @@ import {
import type { WorkspaceMutationTarget } from './workspaceTools'
import {
globalToolsFor,
loadWorkspaceSkills,
prepareGlobalSystemMessage,
prepareGlobalUserMessage,
type AiSkillListItem,
type GlobalToolHelpers
} from './global/core'
import { isGlobalAiEnabled } from './global/gate'
@@ -322,6 +324,12 @@ export class AIChatManager {
// session rather than the UI-active one — keeps backgrounded sessions isolated.
sessionId: string | undefined = undefined
// Workspace AI skills (name + description) advertised in the GLOBAL system
// prompt. Loaded asynchronously when entering GLOBAL mode; the system message
// is rebuilt once they resolve.
private globalSkills: AiSkillListItem[] = []
private globalSkillsRefreshId = 0
allowedModes: Record<AIMode, boolean> = $derived({
script:
this.flowAiChatHelpers === undefined &&
@@ -814,7 +822,8 @@ export class AIChatManager {
} else if (mode === AIMode.GLOBAL) {
const customPrompt = getCombinedCustomPrompt(mode)
this.systemMessage = prepareGlobalSystemMessage(customPrompt, {
previewTools: this.isSessionChat
previewTools: this.isSessionChat,
skills: this.globalSkills
})
this.tools = globalToolsFor({ sessionPreview: this.isSessionChat })
this.helpers = {
@@ -823,6 +832,7 @@ export class AIChatManager {
this.flowAiChatHelpers?.testFlow(args),
attachedFiles: this.attachedFiles
} satisfies GlobalToolHelpers
void this.refreshGlobalSkills()
} else if (mode === AIMode.APP) {
const customPrompt = getCombinedCustomPrompt(mode)
this.systemMessage = prepareAppSystemMessage(customPrompt)
@@ -831,6 +841,24 @@ export class AIChatManager {
}
}
// Fetch the workspace's AI skills and, if GLOBAL mode is still active, rebuild
// the system message so the next chat-loop iteration advertises them. Ignore
// stale resolves so workspace changes cannot overwrite newer skills.
private refreshGlobalSkills = async (workspace = get(workspaceStore) ?? '') => {
const refreshId = ++this.globalSkillsRefreshId
const skills = await loadWorkspaceSkills(workspace)
if (refreshId !== this.globalSkillsRefreshId) {
return
}
this.globalSkills = skills
if (this.mode === AIMode.GLOBAL) {
this.systemMessage = prepareGlobalSystemMessage(getCombinedCustomPrompt(AIMode.GLOBAL), {
previewTools: this.isSessionChat,
skills
})
}
}
canApplyCode = $derived(this.allowedModes.script && this.mode === AIMode.SCRIPT)
private changeModeTool = {
@@ -1249,6 +1277,11 @@ export class AIChatManager {
return false
}
}
// Session chats commit their workspace in beforeSend; skills must match the
// committed workspace before the system prompt is sent.
if (this.mode === AIMode.GLOBAL) {
await this.refreshGlobalSkills(get(workspaceStore) ?? '')
}
const isFirstUserTurn = !this.displayMessages.some((message) => message.role === 'user')
// Declared outside `try` so the catch can recover what the loop produced
// before a failure: the structured messages and the latest streamed text
@@ -15,7 +15,9 @@ const mocks = vi.hoisted(() => ({
getOpenaiClient: vi.fn(),
getAnthropicClient: vi.fn(),
getNonStreamingCompletion: vi.fn(),
runChatLoop: vi.fn()
runChatLoop: vi.fn(),
listAiSkills: vi.fn(),
workspace: 'test_workspace' as string | undefined
}))
vi.mock('monaco-editor', () => ({
@@ -24,7 +26,8 @@ vi.mock('monaco-editor', () => ({
vi.mock('$lib/gen', () => ({
WorkspaceService: {
logAiChat: mocks.logAiChat
logAiChat: mocks.logAiChat,
listAiSkills: mocks.listAiSkills
},
ScriptService: {},
FlowService: {},
@@ -36,7 +39,12 @@ vi.mock('$lib/gen', () => ({
const TEST_EMAIL = 'admin@test'
vi.mock('$lib/stores', () => ({
workspaceStore: { subscribe: () => () => undefined },
workspaceStore: {
subscribe: (run: (value: string | undefined) => void) => {
run(mocks.workspace)
return () => undefined
}
},
userStore: {
subscribe: (run: (value: { username: string; email: string }) => void) => {
run({ username: 'admin', email: 'admin@test' })
@@ -95,6 +103,8 @@ beforeEach(() => {
mocks.logAiChat.mockResolvedValue(undefined)
mocks.getOpenaiClient.mockReturnValue({})
mocks.getAnthropicClient.mockReturnValue({})
mocks.listAiSkills.mockResolvedValue([])
mocks.workspace = 'test_workspace'
mocks.runChatLoop.mockResolvedValue({
addedMessages: [],
tokenUsage: { prompt: 0, completion: 0, total: 0 },
@@ -185,6 +195,57 @@ describe('AIChatManager request errors', () => {
})
})
describe('AIChatManager global skills', () => {
const model = { provider: 'openai', model: 'gpt-4o' }
beforeEach(() => {
localStorage.clear()
mocks.getCurrentModel.mockReturnValue(model)
mocks.tryGetCurrentModel.mockReturnValue(model)
})
it('loads skills after beforeSend commits the session workspace', async () => {
let resolveParentSkills: ((skills: { name: string; description: string }[]) => void) | undefined
const parentSkills = new Promise<{ name: string; description: string }[]>((resolve) => {
resolveParentSkills = resolve
})
mocks.workspace = 'parent'
mocks.listAiSkills.mockImplementation(({ workspace }: { workspace: string }) => {
if (workspace === 'parent') {
return parentSkills
}
return Promise.resolve([{ name: 'child-skill', description: 'child workspace skill' }])
})
mocks.runChatLoop.mockImplementation(async (config: any) => {
expect(config.workspace).toBe('child')
expect(config.systemMessage.content).toContain('child-skill')
expect(config.systemMessage.content).not.toContain('parent-skill')
const message = { role: 'assistant' as const, content: 'done' }
config.addedMessages?.push(message)
return {
addedMessages: [message],
tokenUsage: { prompt: 0, completion: 0, total: 0 },
hitMaxIterations: false
}
})
const manager = new AIChatManager()
manager.isSessionChat = true
manager.beforeSend = () => {
mocks.workspace = 'child'
}
await manager.sendRequest({ instructions: 'first', mode: AIMode.GLOBAL })
resolveParentSkills?.([{ name: 'parent-skill', description: 'parent workspace skill' }])
await Promise.resolve()
expect(mocks.listAiSkills).toHaveBeenCalledWith({ workspace: 'parent' })
expect(mocks.listAiSkills).toHaveBeenCalledWith({ workspace: 'child' })
expect(manager.systemMessage.content).toContain('child-skill')
expect(manager.systemMessage.content).not.toContain('parent-skill')
})
})
describe('AIChatManager autonomy mode', () => {
beforeEach(() => {
localStorage.clear()
@@ -916,12 +977,10 @@ describe('AIChatManager context compaction', () => {
mocks.tryGetCurrentModel.mockReturnValue(gpt4oModel)
// The user hits Stop while the summary request is in flight: it aborts the
// turn's controller and rejects.
mocks.getNonStreamingCompletion.mockImplementation(
async (_msgs: any, ac: AbortController) => {
ac.abort('user_cancelled')
throw new Error('aborted')
}
)
mocks.getNonStreamingCompletion.mockImplementation(async (_msgs: any, ac: AbortController) => {
ac.abort('user_cancelled')
throw new Error('aborted')
})
// With the controller already aborted, the real request returns nothing;
// mirror that so the turn takes the cancel/rollback path.
mocks.runChatLoop.mockImplementation(async () => ({
@@ -135,7 +135,8 @@
await acceptPendingFlowEditsIfEnabled()
},
getFlowInputsSchema: async () => {
return flowStore.val.schema ?? {}
const s = flowStore.val.schema ?? {}
return { type: 'object', properties: {}, required: [], ...s }
},
updateExprsToSet: (id: string, inputTransforms: Record<string, InputTransform>) => {
@@ -14,7 +14,8 @@ import {
ScriptService,
SqsTriggerService,
VariableService,
WebsocketTriggerService
WebsocketTriggerService,
WorkspaceService
} from '$lib/gen'
import { $ScriptLang } from '$lib/gen/schemas.gen'
import type {
@@ -732,7 +733,8 @@ function buildFolderGuidance(username: string, ctx?: FolderPromptContext): strin
const buildGlobalSystemPrompt = (
username: string,
previewTools: boolean,
folderCtx?: FolderPromptContext
folderCtx?: FolderPromptContext,
skills: AiSkillListItem[] = []
) => {
const folderGuidance = buildFolderGuidance(username, folderCtx)
const folderGuidanceBlock = folderGuidance ? `\n${folderGuidance}` : ''
@@ -793,7 +795,16 @@ Data Tables:
- Use list_datatables to discover the available datatables and their tables. Reuse an existing table rather than creating a duplicate. If list_datatables reports none, this is a blocking prerequisite tell the user to set up a datatable in their workspace settings and stop; do not assume a "main" datatable exists or call exec_datatable_sql.
- Use get_datatable_table_schema only when you need a table's column names/types; list_datatables is enough for table-list or availability summaries.
- Use exec_datatable_sql to explore data, run queries, mutate rows, or change schema (CREATE/ALTER/DROP). Creating a table is a normal CREATE TABLE statement it appears in list_datatables afterward, with no registration step.
- When writing runnable code (inline app runnables, scripts, flow modules) that reads or writes datatable data at runtime, it accesses a datatable via wmill.datatable(). Default to TypeScript (bun) unless the user asked for another language. Call get_instructions with subject "datatable" and language "bun" for the TypeScript SQL SDK reference (or language "python3" for Python) it returns only that language so you get just what you need.`
- When writing runnable code (inline app runnables, scripts, flow modules) that reads or writes datatable data at runtime, it accesses a datatable via wmill.datatable(). Default to TypeScript (bun) unless the user asked for another language. Call get_instructions with subject "datatable" and language "bun" for the TypeScript SQL SDK reference (or language "python3" for Python) it returns only that language so you get just what you need.${
skills.length > 0
? `
Skills:
- Skills are reusable instruction sets curated for this workspace, each covering a specific kind of task. The available skills are listed below by name and description.
- When a user's request matches a skill's description, call read_skill with its exact name to load the full instructions BEFORE acting, then follow them.
${skills.map((s) => `- ${s.name}: ${s.description}`).join('\n')}`
: ''
}`
}
const DEFAULT_LIST_TYPES = ['script', 'flow'] as const satisfies readonly WorkspaceItemType[]
@@ -1545,7 +1556,51 @@ function getInstructions(subject: InstructionSubject, language?: ScriptLang): st
}
}
export type AiSkillListItem = { name: string; description: string }
/** Fetch the workspace's AI skills (name + description) for the global system prompt. */
export async function loadWorkspaceSkills(workspace: string): Promise<AiSkillListItem[]> {
if (!workspace) return []
try {
return await WorkspaceService.listAiSkills({ workspace })
} catch (e) {
console.error('Failed to load AI skills', e)
return []
}
}
const readSkillSchema = z.object({
name: z
.string()
.describe('The exact skill name as listed in the Skills section of the system prompt.')
})
export const readSkillTool: Tool<{}> = {
def: createToolDef(
readSkillSchema,
'read_skill',
'Load the full instructions for a workspace AI skill by name. Skills are listed in the system prompt under "Skills"; call this before acting on a task a skill covers, then follow its instructions.'
),
fn: async ({ args, workspace, toolId, toolCallbacks }) => {
const parsed = readSkillSchema.parse(args)
toolCallbacks.setToolStatus(toolId, { content: `Reading skill "${parsed.name}"...` })
try {
const skill = await WorkspaceService.getAiSkill({ workspace, name: parsed.name })
toolCallbacks.setToolStatus(toolId, { content: `Read skill "${parsed.name}"` })
return `Skill: ${skill.name}\nDescription: ${skill.description}\n\nInstructions:\n${skill.instructions}`
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
toolCallbacks.setToolStatus(toolId, {
content: `Error reading skill "${parsed.name}"`,
error: msg
})
return `Failed to read skill "${parsed.name}": ${msg}. Check the name against the Skills list in the system prompt.`
}
}
}
export const globalTools: Tool<{}>[] = [
readSkillTool,
{
def: createToolDef(
getInstructionsSchema,
@@ -4001,6 +4056,7 @@ export function prepareGlobalSystemMessage(
// (read from userStore); callers that must not touch the process-global
// store (the eval harness) pass it explicitly instead.
user?: { username: string; is_admin?: boolean; folders?: string[]; folders_read?: string[] }
skills?: AiSkillListItem[]
}
): ChatCompletionSystemMessageParam {
const user = opts?.user ?? get(userStore)
@@ -4012,7 +4068,12 @@ export function prepareGlobalSystemMessage(
isAdmin: user.is_admin ?? false
}
: undefined
let content = buildGlobalSystemPrompt(username, opts?.previewTools ?? false, folderCtx)
let content = buildGlobalSystemPrompt(
username,
opts?.previewTools ?? false,
folderCtx,
opts?.skills ?? []
)
if (customPrompt?.trim()) {
content = `${content}\n\nUSER GIVEN INSTRUCTIONS:\n${customPrompt.trim()}`
}
@@ -936,7 +936,9 @@ export async function buildSchemaForTool(
throw new Error(`Invalid flow inputs schema: ${invalidProperties.join(', ')}`)
}
toolDef.function.parameters = { ...schema, additionalProperties: false }
// Anthropic requires input_schema.type to be present; flows with no inputs
// can produce a sparse schema (e.g. { order: [] }) lacking it.
toolDef.function.parameters = { type: 'object', ...schema, additionalProperties: false }
// recursively normalize provider-incompatible schema fragments
normalizeToolParameterSchema(toolDef.function.parameters)
@@ -15,6 +15,16 @@
jobsById?: Record<string, JobById>
editor: boolean
workspace: string
/**
* Restrict waitJob/getJob/streamJob to job ids launched by this app
* instance (WIN-2006): a SANDBOXED bundle must not read arbitrary
* workspace jobs through the credentialed bridge. Off for unsandboxed
* renders (the default, and editor preview) — there the bundle holds
* the same credential as the bridge, so gating adds nothing and would
* only break unsandboxed apps that poll persisted or runnable-returned
* job ids.
*/
gateJobIds?: boolean
}
let {
@@ -24,10 +34,18 @@
jobs = $bindable([]),
jobsById = $bindable({}),
editor,
workspace
workspace,
gateJobIds = true
}: Props = $props()
// Job ids launched by this app instance — see `gateJobIds`.
const launchedJobs = new Set<string>()
let listener = async (event) => {
// Only accept messages from the bundle iframe (opaque origin) so other
// frames/extensions can't drive the runnable bridge (WIN-2006). Reject
// unconditionally until the iframe is bound — never process a message from
// an unknown source.
if (!iframe || event.source !== iframe.contentWindow) return
const data = event.data
@@ -115,6 +133,7 @@
},
undefined
)
launchedJobs.add(uuid)
let job: JobById = { component: runnable_id, created_at: Date.now(), job: uuid }
if (event.data.type == 'backendAsync') {
let result = uuid
@@ -134,14 +153,29 @@
console.error('No runnable found for', runnable_id)
}
} else if (event.data.type == 'waitJob') {
if (gateJobIds && !launchedJobs.has(data.jobId)) {
respond({ result: { message: 'Unknown job' }, error: true })
return
}
await respondWithResult(data.jobId)
} else if (event.data.type == 'getJob') {
if (gateJobIds && !launchedJobs.has(data.jobId)) {
respond({ result: { message: 'Unknown job' }, error: true })
return
}
const job = await JobService.getJob({ workspace, id: data.jobId })
respond({ result: job })
} else if (event.data.type == 'streamJob') {
// Stream job results using SSE
const jobId = data.jobId
const reqId = data.reqId
if (gateJobIds && !launchedJobs.has(jobId)) {
iframe?.contentWindow?.postMessage(
{ type: 'streamJobRes', reqId, error: true, result: { message: 'Unknown job' } },
'*'
)
return
}
const params = new URLSearchParams()
params.set('fast', 'true')
params.set('only_result', 'true')
@@ -1496,6 +1496,7 @@
bind:jobsById
{runnables}
{path}
gateJobIds={false}
/>
<div class="max-h-screen overflow-hidden h-screen min-h-0 flex flex-col">
<RawAppEditorHeader
@@ -473,14 +473,16 @@
onDeploy?.({ path: npath })
}
async function setPublishState() {
async function setPublishState(message?: string) {
await computeTriggerables()
await AppService.updateApp({
workspace: $workspaceStore!,
path: appPath,
requestBody: { policy }
})
if (policy.execution_mode == 'anonymous') {
if (message) {
sendUserToast(message)
} else if (policy.execution_mode == 'anonymous') {
sendUserToast('App require no login to be accessed')
} else {
sendUserToast('App require login and read-access')
@@ -2,8 +2,8 @@
import { type UserExt } from '$lib/stores'
import RawAppBackgroundRunner from './RawAppBackgroundRunner.svelte'
import type { Runnable } from './rawAppPolicy'
import { htmlContent } from './utils'
import { onMount, untrack } from 'svelte'
import { getContext, onMount, untrack } from 'svelte'
import { unsandboxedRawAppHtml } from './utils'
interface Props {
workspace: string
@@ -17,43 +17,190 @@
let iframe = $state() as HTMLIFrameElement | undefined
// Get initial hash from parent URL to pass to iframe
let initialHash = $state('')
// Get initial hash from parent URL to pass to the iframe
let initialHash = ''
onMount(() => {
initialHash = window.location.hash || ''
// WIN-2006: unless the publisher opted into sandbox isolation, run the bundle
// same-origin with full access (the default); otherwise the opaque-origin sandbox.
const unsandboxedCtx = getContext<{ value: boolean }>('IS_APP_UNSANDBOXED')
let unsandboxed = $derived(unsandboxedCtx?.value ?? false)
// Unsandboxed (the default) must match the pre-isolation viewer exactly: NO
// sandbox attribute (a same-origin blob with full session — an attribute would
// only break leftover features like unsandboxed popups for OAuth flows, while
// adding no isolation). The sandboxed path keeps the restrictive attribute; the
// wrapper document's `CSP: sandbox` response header enforces the opaque origin
// regardless.
let sandboxAttr = $derived(
unsandboxed
? undefined
: 'allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox allow-downloads allow-modals allow-top-navigation'
)
// WIN-2006: source of the bundle iframe.
// - DEFAULT (isolated): a real API URL serving a sandboxed, opaque-origin
// document (`CSP: sandbox` response header + the iframe sandbox attribute),
// so a malicious bundle can never reach the authenticated Windmill origin
// (no cookie, no window.parent, no token). Root-relative so it resolves
// against the real host even when this component itself runs inside an opaque
// viewer (where `location.origin` is "null"). Context is handed over via
// postMessage — never baked into the document, never a credential.
// - UNSANDBOXED (the default — publisher did not opt into isolation): a
// client-built blob: wrapper (same-origin with the SPA) loaded with `allow-same-origin`,
// so relative `fetch('/api/...')` and the session cookie work. The backend
// `.html` is ALWAYS sandboxed, so we must build the same-origin wrapper here
// rather than relax a real-origin endpoint a victim could be linked to.
let iframeSrc = $derived.by(() => {
if (!secret || typeof window === 'undefined') return undefined
if (unsandboxed) {
// untrack(user) so userStore refreshes don't regenerate the blob URL and
// reload the iframe (losing state); ctx is only needed for initial render.
// Always pass the wrapper object — pre-sandbox bundles rely on
// `window.ctx.workspace` even for anonymous viewers (ctx.ctx undefined).
const u = untrack(() => user)
const html = unsandboxedRawAppHtml(
workspace,
secret,
{ ctx: u, workspace },
window.location.origin,
window.location.hash || ''
)
return URL.createObjectURL(new Blob([html], { type: 'text/html' }))
}
// `wm_coep` (embed-in-cross-origin-isolated-page opt-in) must be propagated
// to the wrapper document: under a COEP `require-corp` embedder, a nested
// document is only allowed to load if it asserts COEP itself, so the
// backend adds the header when the flag is present.
const coep = new URLSearchParams(window.location.search).has('wm_coep') ? '?wm_coep=1' : ''
return `/api/w/${workspace}/apps_u/get_data/v/${secret}.html${coep}`
})
// Use blob URL instead of srcDoc to give the iframe a proper origin.
// srcDoc iframes have "null" origin which breaks URL constructor in routers.
// untrack(user) so that userStore refreshes don't regenerate the blob URL
// and cause the iframe to fully reload (losing all state).
// The user context is only needed for initial render.
let blobUrl = $derived.by(() => {
if (!secret) return undefined
const u = untrack(() => user)
const baseUrl = typeof window !== 'undefined' ? window.location.origin : ''
const html = htmlContent(workspace, secret, { ctx: u, workspace }, baseUrl, initialHash)
const blob = new Blob([html], { type: 'text/html' })
return URL.createObjectURL(blob)
})
// Cleanup blob URL when it changes or component unmounts
// Revoke blob: URLs (unsandboxed path) when they change or on unmount.
$effect(() => {
const url = blobUrl
const url = iframeSrc
return () => {
if (url) URL.revokeObjectURL(url)
if (url && url.startsWith('blob:')) URL.revokeObjectURL(url)
}
})
// Persistence for the bundle's (opaque-origin) localStorage, backed by a store
// scoped PER APP (keyed by workspace + app path) so one sandboxed app can't read
// or clobber another's (even two apps at the same path in different workspaces). On a real origin (workspace viewer, public page — even when
// that page sits inside someone else's iframe) it reads/writes real localStorage
// directly. Only inside an opaque frame (the Windmill embed viewer), where Web
// Storage throws, does it relay per-key ops up to the embedder, the persistence
// authority. `framed` therefore probes storage rather than just `window.parent`:
// an externally-embedded public page is framed too, but its parent is not the
// Windmill embedder and would never answer the relay (leaving the bundle without
// ctx). The snapshot is handed to the bundle before it evaluates so its
// localStorage is hydrated synchronously.
const SHARED_LS_KEY = `wm_apps_localstorage:${workspace}:${path}`
function storageAccessible(): boolean {
try {
localStorage.getItem(SHARED_LS_KEY)
return true
} catch (_) {
return false
}
}
const framed = typeof window !== 'undefined' && window.parent !== window && !storageAccessible()
let bundleStorage: Record<string, string> | undefined = undefined
let pendingReady = false
function readDirect(): Record<string, string> {
try {
return JSON.parse(localStorage.getItem(SHARED_LS_KEY) || '{}')
} catch (_) {
return {}
}
}
function applyDirectOp(d: any) {
try {
const s = readDirect()
if (d.op === 'set') s[d.key] = String(d.value)
else if (d.op === 'remove') delete s[d.key]
else if (d.op === 'clear') for (const k in s) delete s[k]
localStorage.setItem(SHARED_LS_KEY, JSON.stringify(s))
} catch (_) {}
}
function respondCtx() {
iframe?.contentWindow?.postMessage(
{
type: 'windmill:ctx',
// Same shape as the unsandboxed wrapper: always the object, so
// `window.ctx.workspace` works for anonymous viewers too.
ctx: { ctx: user, workspace },
initialHash,
storage: { local: bundleStorage ?? {}, session: {} }
},
'*'
)
}
onMount(() => {
initialHash = window.location.hash || ''
if (framed) {
// Pre-fetch the shared store from the embedder.
try {
window.parent.postMessage({ type: 'wm_ls_req' }, '*')
} catch (_) {}
// If the parent never answers (it isn't the Windmill embedder, e.g. an
// opaque context created by a third party), don't hold the bundle's ctx
// hostage: proceed with empty storage. Must beat the backend wrapper's
// own 1.5s no-ctx fallback.
const fallback = setTimeout(() => {
if (bundleStorage === undefined) {
bundleStorage = {}
if (pendingReady) {
pendingReady = false
respondCtx()
}
}
}, 750)
return () => clearTimeout(fallback)
}
})
// Listen for hash changes from iframe and update parent URL
$effect(() => {
function handleMessage(event: MessageEvent) {
console.log('[Parent] Received message:', event.data)
if (event.data?.type === 'windmill:hashchange') {
const newHash = event.data.hash || ''
console.log('[Parent] Updating hash to:', newHash)
// Update parent URL without triggering navigation
const data = event.data
// Shared-store hydration from the embedder (public mode only).
if (framed && event.source === window.parent && data?.type === 'wm_ls_hydrate') {
bundleStorage = data.data || {}
if (pendingReady) {
pendingReady = false
respondCtx()
}
return
}
// Everything else must come from the bundle iframe.
if (event.source !== iframe?.contentWindow) return
if (data?.type === 'windmill:ready') {
// Hand the bundle its context + shared storage before it evaluates.
if (!framed) {
bundleStorage = readDirect()
respondCtx()
} else if (bundleStorage !== undefined) {
respondCtx()
} else {
pendingReady = true
}
} else if (data?.type === 'wm_ls_op') {
// The bundle mutated localStorage — apply it to the shared store.
if (!framed) {
applyDirectOp(data)
} else {
try {
window.parent.postMessage(
{ type: 'wm_ls_op', op: data.op, key: data.key, value: data.value },
'*'
)
} catch (_) {}
}
} else if (data?.type === 'windmill:hashchange') {
// Keep the parent URL hash in sync for shareable URLs.
const newHash = data.hash || ''
if (window.location.hash !== newHash) {
history.replaceState(null, '', newHash || window.location.pathname)
}
@@ -65,13 +212,29 @@
})
</script>
<RawAppBackgroundRunner {workspace} editor={false} {iframe} {runnables} {path} />
<RawAppBackgroundRunner
{workspace}
editor={false}
{iframe}
{runnables}
{path}
gateJobIds={!unsandboxed}
/>
{#if blobUrl}
{#if iframeSrc}
<!-- `unsandboxed` (the default — publisher did not opt into isolation) adds
allow-same-origin and loads a same-origin blob: wrapper, so the bundle runs
with full access. The sandboxed path loads the always-CSP-sandboxed backend
wrapper, which stays opaque even on direct navigation. -->
<!-- referrerpolicy (sandboxed only, for exact legacy parity): the hosting page
URL can carry a viewer credential (the JWT path segment of share links);
without this, the bundle document would see it via document.referrer. -->
<iframe
bind:this={iframe}
title="raw-app"
src={blobUrl}
src={iframeSrc}
sandbox={sandboxAttr}
referrerpolicy={unsandboxed ? undefined : 'no-referrer'}
class="w-full h-full min-h-screen bg-white border-none"
></iframe>
{/if}
@@ -19,10 +19,11 @@ export async function updateRawAppPolicy(
)
).filter((entry): entry is [string, TriggerableV2] => entry != null)
const triggerables_v2 = Object.fromEntries(entries)
return {
const next: Policy = {
...currentPolicy,
triggerables_v2
}
return next
}
type RunnableWithInlineScript = RunnableWithFields & {
+31 -46
View File
@@ -141,65 +141,50 @@ export function formatAppRunsForChat(runs: RawAppRunSummary[]): string {
return JSON.stringify(runs, null, 2)
}
export function htmlContent(
// The sandboxed (isolated) raw-app wrapper is generated server-side and served as
// a sandboxed, opaque-origin document (see `get_raw_app_data` in the backend
// `apps.rs`, WIN-2006) — a blob: URL cannot carry the `CSP: sandbox` response
// header that enforces isolation, so the wrapper must come from the backend.
//
// The function below is used ONLY for the unsandboxed path (the default — the
// publisher did not opt into sandbox isolation). It is loaded as a blob: URL —
// same-origin with the SPA — so, with `allow-same-origin`, the bundle runs with
// the viewer's full session. Crucially this is an in-memory blob, not a
// real-origin endpoint, so it is not a URL an attacker can navigate a logged-in
// victim to in order to gain isolation-bypassing access — the backend `.html`
// document stays sandboxed whenever the publisher did opt in.
export function unsandboxedRawAppHtml(
workspace: string,
secret: string | undefined,
secret: string,
ctx: any,
baseUrl: string = '',
initialHash: string = ''
baseUrl: string,
initialHash: string
) {
return `<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<title>App Preview</title>
<title>App</title>
<link rel="stylesheet" href="${baseUrl}/api/w/${workspace}/apps_u/get_data/v/${secret}.css" />
<script>
window.ctx = ${ctx ? JSON.stringify(ctx) : 'undefined'};
// Sync hash with parent window for shareable URLs
(function() {
// Set initial hash from parent URL
(function () {
// Keep the parent URL hash in sync for shareable URLs.
function notifyParent() {
try {
if (window.parent !== window) {
window.parent.postMessage({ type: 'windmill:hashchange', hash: window.location.hash }, '*');
}
} catch (_) {}
}
var initialHash = ${JSON.stringify(initialHash)};
if (initialHash && initialHash !== '#' && !window.location.hash) {
history.replaceState(null, '', initialHash);
try { history.replaceState(null, '', initialHash); } catch (_) {}
}
// Notify parent when hash changes
function notifyParent() {
var hash = window.location.hash;
console.log('[HashSync] notifyParent called, hash:', hash);
if (window.parent !== window) {
window.parent.postMessage({
type: 'windmill:hashchange',
hash: hash
}, '*');
}
}
// Listen for hash changes
window.addEventListener('hashchange', function() {
console.log('[HashSync] hashchange event');
notifyParent();
});
// Also notify on pushState/replaceState
var originalPushState = history.pushState;
var originalReplaceState = history.replaceState;
history.pushState = function() {
console.log('[HashSync] pushState called with:', arguments[2]);
originalPushState.apply(this, arguments);
notifyParent();
};
history.replaceState = function() {
console.log('[HashSync] replaceState called with:', arguments[2]);
originalReplaceState.apply(this, arguments);
notifyParent();
};
// Notify parent of initial hash after load
window.addEventListener('hashchange', notifyParent);
var _ps = history.pushState, _rs = history.replaceState;
history.pushState = function () { _ps.apply(this, arguments); notifyParent(); };
history.replaceState = function () { _rs.apply(this, arguments); notifyParent(); };
setTimeout(notifyParent, 0);
})();
</script>
@@ -1,5 +1,6 @@
<script lang="ts">
import { run } from 'svelte/legacy'
import { onDestroy } from 'svelte'
import { enterpriseLicense, userStore, workspaceStore, awarenessStore } from '$lib/stores'
@@ -23,7 +24,17 @@
url: $page.url.pathname
})
}
function disconnectWorkspace() {
if (wsProvider) {
wsProvider.destroy()
wsProvider = undefined
}
connected = false
awareness = undefined
}
async function connectWorkspace(workspace: string) {
disconnectWorkspace()
let token: string | undefined
try {
token = await signMultiplayerRequest(workspace)
@@ -73,6 +84,10 @@
$enterpriseLicense && $workspaceStore && connectWorkspace($workspaceStore)
})
onDestroy(() => {
disconnectWorkspace()
})
let peers = $derived(
Object.entries($awarenessStore ?? {}).filter(
([user]) => user && user !== 'undefined' && user !== 'null'
@@ -13,6 +13,7 @@
import { supportsAutocomplete } from '../copilot/utils'
import TestAiKey from '../copilot/TestAIKey.svelte'
import Label from '../Label.svelte'
import AiSkillsSettings from './AiSkillsSettings.svelte'
import SettingsPageHeader from '../settings/SettingsPageHeader.svelte'
import ResourcePicker from '../ResourcePicker.svelte'
import Toggle from '../Toggle.svelte'
@@ -587,6 +588,10 @@
</div>
</SettingCard>
{/if}
{#if promptScope === 'workspace'}
<AiSkillsSettings />
{/if}
</div>
<AIPromptsModal
@@ -0,0 +1,396 @@
<script lang="ts">
import { onMount } from 'svelte'
import YAML from 'yaml'
import Button from '../common/button/Button.svelte'
import ConfirmationModal from '../common/confirmationModal/ConfirmationModal.svelte'
import SettingCard from '../instanceSettings/SettingCard.svelte'
import Label from '../Label.svelte'
import autosize from '$lib/autosize'
import { workspaceStore } from '$lib/stores'
import { sendUserToast } from '$lib/toast'
import { WorkspaceService } from '$lib/gen'
import { FolderUp, Plus, Trash2 } from 'lucide-svelte'
type SkillListItem = { name: string; description: string }
type SkillUpload = { name: string; description: string; instructions: string }
// `<root>/<skill>/SKILL.md` is 3 path segments; SKILL.md files nested deeper
// are likely vendored/incidental and are skipped so importing a parent dir
// doesn't sweep in unrelated skills.
const MAX_SKILL_DEPTH = 3
const MAX_SKILLS_PER_IMPORT = 50
const MAX_SKILLS_PER_WORKSPACE = 100
// `name` + `description` mirror the Claude SKILL.md spec (counted in
// characters); the body is a byte-bounded payload. Keep these in sync with
// backend `validate_skill`.
const MAX_SKILL_NAME_LENGTH = 64
const MAX_SKILL_DESCRIPTION_LENGTH = 1_024
const MAX_SKILL_INSTRUCTIONS_LENGTH = 64 * 1024
const SKILL_NAME_PATTERN = /^[a-z0-9-]+$/
const textEncoder = new TextEncoder()
const SAMPLE_SKILL_PLACEHOLDER =
'---\nname: my-skill\ndescription: what this skill helps with\n---\n\n# My skill\n\nInstructions for the assistant…'
let skills: SkillListItem[] = $state([])
let uploading: boolean = $state(false)
let pasteContent: string = $state('')
let dirInput: HTMLInputElement | undefined = $state(undefined)
let toDelete: string | undefined = $state(undefined)
let pendingImport: SkillUpload[] | undefined = $state(undefined)
let pendingSkipped: string[] = $state([])
let listRequestId = 0
let pendingNamesPreview = $derived.by(() => {
const p = pendingImport ?? []
const shown = p
.slice(0, 12)
.map((s) => s.name)
.join(', ')
return p.length > 12 ? `${shown}, … (+${p.length - 12} more)` : shown
})
async function loadList(workspace: string | undefined) {
const requestId = ++listRequestId
if (!workspace) {
skills = []
return
}
try {
const loaded = await WorkspaceService.listAiSkills({ workspace })
if (requestId === listRequestId && workspace === $workspaceStore) {
skills = loaded
}
} catch (e) {
if (requestId === listRequestId && workspace === $workspaceStore) {
sendUserToast(`Failed to load skills: ${e}`, true)
}
}
}
/** Split a SKILL.md into its frontmatter `name`/`description` and the markdown body. */
function parseSkillMd(raw: string): {
name: string | undefined
description: string | undefined
instructions: string
} {
const text = raw.replace(/^/, '')
const fm = /^---\s*\r?\n([\s\S]*?)\r?\n---\s*\r?\n?/.exec(text)
if (!fm) {
return { name: undefined, description: undefined, instructions: text.trim() }
}
let name: string | undefined
let description: string | undefined
try {
const data = YAML.parse(fm[1]) ?? {}
if (typeof data?.name === 'string') name = data.name.trim()
if (typeof data?.description === 'string') description = data.description.trim()
} catch {
// Malformed frontmatter — fall through so the skill is reported as
// invalid rather than silently dropped.
}
return { name, description, instructions: text.slice(fm[0].length).trim() }
}
function validateParsedSkill(skill: SkillUpload): string | undefined {
if ([...skill.name].length > MAX_SKILL_NAME_LENGTH) {
return `name is longer than ${MAX_SKILL_NAME_LENGTH} characters`
}
if (!SKILL_NAME_PATTERN.test(skill.name)) {
return `name ${JSON.stringify(skill.name)} must only contain lowercase letters, digits or '-'`
}
if ([...skill.description].length > MAX_SKILL_DESCRIPTION_LENGTH) {
return `description is longer than ${MAX_SKILL_DESCRIPTION_LENGTH} characters`
}
if (textEncoder.encode(skill.instructions).byteLength > MAX_SKILL_INSTRUCTIONS_LENGTH) {
return `body is longer than ${MAX_SKILL_INSTRUCTIONS_LENGTH} bytes`
}
}
/**
* Turn a map of `relativePath -> content` (from an imported folder) into skills.
* A skill is any `SKILL.md`; its id is the name of the folder holding it.
*/
function collectSkills(files: Record<string, string>): {
skills: SkillUpload[]
skipped: string[]
} {
const collected: SkillUpload[] = []
const skipped: string[] = []
for (const [path, content] of Object.entries(files)) {
const segments = path.split('/')
if (segments[segments.length - 1]?.toLowerCase() !== 'skill.md') continue
const name = segments.length >= 2 ? segments[segments.length - 2] : ''
const { description, instructions } = parseSkillMd(content)
if (!name) {
skipped.push(`${path} (SKILL.md must live in a named folder)`)
} else if (!description) {
skipped.push(`${name} (missing frontmatter description)`)
} else if (!instructions) {
skipped.push(`${name} (empty body)`)
} else {
const parsed = { name, description, instructions }
const validationError = validateParsedSkill(parsed)
if (validationError) {
skipped.push(`${name} (${validationError})`)
} else {
collected.push(parsed)
}
}
}
return { skills: collected, skipped }
}
async function uploadSkills(parsed: SkillUpload[], skipped: string[] = []) {
const workspace = $workspaceStore
if (!workspace || parsed.length === 0) {
sendUserToast(
`No valid skill found.${skipped.length ? ` Skipped: ${skipped.join(', ')}` : ''}`,
true
)
return false
}
if (parsed.length > MAX_SKILLS_PER_IMPORT) {
sendUserToast(`Cannot add more than ${MAX_SKILLS_PER_IMPORT} skills at a time.`, true)
return false
}
// Uploads upsert, so only names not already stored count toward the cap.
const existingNames = new Set(skills.map((s) => s.name))
const newCount = parsed.filter((s) => !existingNames.has(s.name)).length
if (skills.length + newCount > MAX_SKILLS_PER_WORKSPACE) {
sendUserToast(`This workspace can store at most ${MAX_SKILLS_PER_WORKSPACE} skills.`, true)
return false
}
uploading = true
try {
await WorkspaceService.uploadAiSkills({
workspace,
requestBody: { skills: parsed }
})
let message = `Added ${parsed.length} skill(s)`
if (skipped.length) message += `; skipped ${skipped.length}: ${skipped.join(', ')}`
sendUserToast(message)
await loadList(workspace)
return true
} catch (e) {
sendUserToast(`Failed to add skills: ${e}`, true)
return false
} finally {
uploading = false
}
}
async function addPastedSkill() {
const { name, description, instructions } = parseSkillMd(pasteContent)
if (!name) {
sendUserToast('The pasted SKILL.md needs a `name` in its frontmatter.', true)
return
}
if (!description) {
sendUserToast('The pasted SKILL.md needs a `description` in its frontmatter.', true)
return
}
if (!instructions) {
sendUserToast('The pasted SKILL.md has an empty body.', true)
return
}
const parsed = { name, description, instructions }
const validationError = validateParsedSkill(parsed)
if (validationError) {
sendUserToast(`The pasted SKILL.md ${validationError}.`, true)
return
}
if (await uploadSkills([parsed])) {
pasteContent = ''
}
}
async function onDirSelected(event: Event) {
const target = event.target as HTMLInputElement
const files = Array.from(target.files ?? [])
// Reset early so re-selecting the same folder re-fires `change`.
if (dirInput) dirInput.value = ''
// Pick SKILL.md files within the depth limit BEFORE reading any content,
// so a huge tree never gets read in full.
const skipped: string[] = []
const eligible: File[] = []
for (const f of files) {
const path = f.webkitRelativePath || f.name
const segments = path.split('/')
if (segments[segments.length - 1]?.toLowerCase() !== 'skill.md') continue
if (segments.length > MAX_SKILL_DEPTH) {
skipped.push(`${path} (nested deeper than ${MAX_SKILL_DEPTH} folder levels)`)
continue
}
eligible.push(f)
}
if (eligible.length === 0) {
sendUserToast(
`No SKILL.md found within ${MAX_SKILL_DEPTH} folder levels.${
skipped.length ? ` Skipped ${skipped.length} deeper file(s).` : ''
}`,
true
)
return
}
if (eligible.length > MAX_SKILLS_PER_IMPORT) {
sendUserToast(
`Found ${eligible.length} skills in this folder; imports are limited to ${MAX_SKILLS_PER_IMPORT} at a time.`,
true
)
return
}
const map: Record<string, string> = {}
for (const f of eligible) {
map[f.webkitRelativePath || f.name] = await f.text()
}
const { skills: parsed, skipped: parseSkipped } = collectSkills(map)
const allSkipped = [...skipped, ...parseSkipped]
if (parsed.length === 0) {
sendUserToast(
`No valid skill found.${allSkipped.length ? ` Skipped: ${allSkipped.join(', ')}` : ''}`,
true
)
return
}
// Confirm before writing — the import can pull in several skills at once.
pendingSkipped = allSkipped
pendingImport = parsed
}
async function deleteSkill(name: string) {
const workspace = $workspaceStore
if (!workspace) return
try {
await WorkspaceService.deleteAiSkill({ workspace, name })
sendUserToast(`Deleted skill ${name}`)
await loadList(workspace)
} catch (e) {
sendUserToast(`Failed to delete skill: ${e}`, true)
}
}
onMount(() => {
return workspaceStore.subscribe((workspace) => {
toDelete = undefined
pendingImport = undefined
pendingSkipped = []
void loadList(workspace)
})
})
</script>
<SettingCard
label="Custom skills"
description="Add your own skills to the AI Chat. The expected format is the same as Claude or Codex."
>
<div class="flex flex-col gap-3 pt-1">
<Label label="Paste a SKILL.md file">
<textarea
bind:value={pasteContent}
placeholder={SAMPLE_SKILL_PLACEHOLDER}
class="w-full min-h-24 p-2 border border-gray-200 dark:border-gray-700 rounded-md bg-surface text-primary font-mono text-xs resize-y"
rows="5"
use:autosize
></textarea>
<div class="flex justify-end mt-2">
<Button
onclick={addPastedSkill}
variant="default"
unifiedSize="sm"
startIcon={{ icon: Plus }}
disabled={!pasteContent.trim() || uploading}
>
Add skill
</Button>
</div>
</Label>
<Label label="Import a folder of skills">
<div class="flex mt-1">
<Button
onclick={() => dirInput?.click()}
variant="default"
unifiedSize="sm"
startIcon={{ icon: FolderUp }}
disabled={uploading}
>
{uploading ? 'Importing…' : 'Import folder'}
</Button>
</div>
<input
bind:this={dirInput}
type="file"
style="display: none;"
onchange={onDirSelected}
{...{ webkitdirectory: true, directory: true }}
/>
</Label>
{#if skills.length > 0}
<div class="rounded-md border divide-y">
{#each skills as skill (skill.name)}
<div class="flex items-center justify-between gap-4 px-3 py-2">
<div class="min-w-0">
<div class="text-xs font-semibold font-mono truncate">{skill.name}</div>
<div class="text-2xs text-secondary truncate">{skill.description}</div>
</div>
<Button
onclick={() => (toDelete = skill.name)}
variant="default"
color="red"
unifiedSize="sm"
startIcon={{ icon: Trash2 }}
iconOnly
/>
</div>
{/each}
</div>
{/if}
</div>
</SettingCard>
<ConfirmationModal
open={pendingImport !== undefined}
title="Import skills"
confirmationText="Import"
onConfirmed={async () => {
const toImport = pendingImport
const skipped = pendingSkipped
pendingImport = undefined
pendingSkipped = []
if (toImport) await uploadSkills(toImport, skipped)
}}
onCanceled={() => {
pendingImport = undefined
pendingSkipped = []
}}
>
<span>
Add {pendingImport?.length} skill(s) to the AI chat?
<span class="font-mono text-xs">{pendingNamesPreview}</span>
{#if pendingSkipped.length}
<br /><span class="text-xs text-secondary"
>{pendingSkipped.length} file(s) will be skipped.</span
>
{/if}
</span>
</ConfirmationModal>
<ConfirmationModal
open={toDelete !== undefined}
title="Delete skill"
confirmationText="Delete"
onConfirmed={async () => {
const name = toDelete
toDelete = undefined
if (name) await deleteSkill(name)
}}
onCanceled={() => (toDelete = undefined)}
>
<span>
Delete the skill <code>{toDelete}</code>? The AI chat will no longer be able to use it.
</span>
</ConfirmationModal>
+4
View File
@@ -186,6 +186,10 @@ export interface SQLSchema {
schema: SQLBaseSchema
publicOnly: boolean | undefined
stringified: string
/** MySQL only: the connection's default database (`DATABASE()`), surfaced by the
* introspection script. Lets the table picker render the default db's tables
* unprefixed even when the connection can also see other (non-system) schemas. */
defaultDb?: string
}
export interface GraphqlSchema {
+7 -6
View File
@@ -1,11 +1,12 @@
/**
* One-off migration from the localStorage UserDraft autosave to the
* DB-backed `draft` table. Runs after `migrateLegacyUserDrafts` (which
* produces the `userdraft/w/{workspace}/{kind}/{path}` keys this reads),
* POSTing each to `/drafts/update` and clearing the source key only on
* success so it's idempotent without a sentinel; failed entries retry next
* mount. Not workspace-gated: keys embed their own workspace and the token
* covers all of them, so gating would orphan other-workspace entries.
* DB-backed `draft` table. Reads the workspace-scoped
* `userdraft/w/{workspace}/{kind}/{path}` keys (written by the editor during
* the interim LS-backed phase, so the embedded workspace is correct), POSTing
* each to `/drafts/update` and clearing the source key only on success so
* it's idempotent without a sentinel; failed entries retry next mount. Not
* workspace-gated: keys embed their own workspace and the token covers all of
* them, so gating would orphan other-workspace entries.
*
* Before uploading, each draft is compared against its deployed version
* (script / flow / app); a draft that's deep-equal to what's deployed carries
+69 -162
View File
@@ -1,6 +1,6 @@
import { describe, it, expect, beforeEach } from 'vitest'
import {
migrateLegacyUserDrafts,
purgeLegacyUserDrafts,
__resetUserDraftLegacyMigrationForTesting
} from './userDraftLegacyMigration'
@@ -8,18 +8,12 @@ function encodeLegacy(value: unknown): string {
return btoa(encodeURIComponent(JSON.stringify(value)))
}
function wrapped<V>(value: V): string {
return JSON.stringify({ value })
}
// Read a migrated entry, strip the GC `lastWrittenAt` stamp so assertions
// can match the `{ value }` shape regardless of when the migration ran.
function storedShape(key: string): string | null {
const raw = localStorage.getItem(key)
if (raw == null) return null
const parsed = JSON.parse(raw)
delete parsed.lastWrittenAt
return JSON.stringify(parsed)
const legacyApp = {
grid: [],
fullscreen: false,
theme: undefined,
unusedInlineScripts: [],
hiddenInlineScripts: []
}
beforeEach(() => {
@@ -27,197 +21,110 @@ beforeEach(() => {
__resetUserDraftLegacyMigrationForTesting()
})
describe('migrateLegacyUserDrafts', () => {
it('migrates a legacy app draft to the workspace-scoped key with a { value } wrapper', () => {
// Shape mirrors what the legacy AppEditor wrote: `encodeState($appStore)`,
// i.e. the inner App value, not the wrapping AppWithLastVersion.
const legacyApp = {
grid: [],
fullscreen: false,
theme: undefined,
unusedInlineScripts: [],
hiddenInlineScripts: []
}
describe('purgeLegacyUserDrafts', () => {
it('drops a recognised legacy app draft without re-creating it under any key', () => {
localStorage.setItem('app-u/me/dashboard', encodeLegacy(legacyApp))
migrateLegacyUserDrafts('main')
purgeLegacyUserDrafts()
expect(localStorage.getItem('app-u/me/dashboard')).toBeNull()
expect(storedShape('userdraft/w/main/app/u/me/dashboard')).toBe(wrapped(legacyApp))
// The workspace-blind key is gone, NOT promoted to a guessed workspace.
expect(localStorage.getItem('userdraft/w/main/app/u/me/dashboard')).toBeNull()
})
it('migrates a legacy empty-path app draft (the `app` literal key)', () => {
const legacyApp = {
grid: [],
fullscreen: false,
unusedInlineScripts: [],
hiddenInlineScripts: []
}
it('drops the empty-path legacy keys (`app` / `flow` / `rawapp` literals)', () => {
localStorage.setItem('app', encodeLegacy(legacyApp))
localStorage.setItem('flow', encodeLegacy({ flow: { summary: '', value: { modules: [] } } }))
localStorage.setItem('rawapp', encodeLegacy({ files: {}, runnables: {}, data: {} }))
migrateLegacyUserDrafts('main')
purgeLegacyUserDrafts()
expect(localStorage.getItem('app')).toBeNull()
expect(storedShape('userdraft/w/main/app/')).toBe(wrapped(legacyApp))
expect(localStorage.getItem('flow')).toBeNull()
expect(localStorage.getItem('rawapp')).toBeNull()
})
it('migrates a legacy flow draft and strips the view-state envelope', () => {
const flow = { summary: 'f', value: { modules: [] }, path: 'u/me/myflow' }
const legacyBundle = {
flow,
path: 'u/me/myflow',
selectedId: 'settings',
draft_triggers: [{ id: 't1' }],
selected_trigger: null,
loadedFromHistory: undefined
}
localStorage.setItem('flow-u/me/myflow', encodeLegacy(legacyBundle))
it('drops recognised legacy flow and raw-app drafts', () => {
localStorage.setItem(
'flow-u/me/myflow',
encodeLegacy({ flow: { summary: 'f', value: { modules: [] } }, selectedId: 'settings' })
)
localStorage.setItem(
'rawapp-u/me/site',
encodeLegacy({ files: { 'index.tsx': 'x' }, runnables: {}, data: {} })
)
migrateLegacyUserDrafts('main')
purgeLegacyUserDrafts()
expect(localStorage.getItem('flow-u/me/myflow')).toBeNull()
// Only the inner Flow survives; the view-state envelope is dropped.
expect(storedShape('userdraft/w/main/flow/u/me/myflow')).toBe(wrapped(flow))
})
it('migrates a legacy raw-app draft, defaulting the new `summary` field', () => {
const legacy = {
files: { 'index.tsx': 'export default () => null' },
runnables: {},
data: { tables: [] }
}
localStorage.setItem('rawapp-u/me/site', encodeLegacy(legacy))
migrateLegacyUserDrafts('main')
expect(localStorage.getItem('rawapp-u/me/site')).toBeNull()
expect(storedShape('userdraft/w/main/raw_app/u/me/site')).toBe(
wrapped({ ...legacy, summary: '' })
)
})
it('preserves an existing new-format entry instead of overwriting it', () => {
// Old and new both exist for the same item — the new one is presumed
// fresher.
localStorage.setItem(
'app-u/me/dash',
encodeLegacy({
grid: [],
fullscreen: false,
unusedInlineScripts: [],
hiddenInlineScripts: []
})
)
const existingNew = wrapped({ value: 'new' })
localStorage.setItem('userdraft/w/main/app/u/me/dash', existingNew)
it('leaves the workspace-scoped interim keys untouched (migrateUserDraftsToDb owns those)', () => {
const interim = JSON.stringify({ value: { modules: [] } })
localStorage.setItem('userdraft/w/main/flow/u/me/keep', interim)
migrateLegacyUserDrafts('main')
purgeLegacyUserDrafts()
expect(localStorage.getItem('app-u/me/dash')).toBeNull()
expect(localStorage.getItem('userdraft/w/main/app/u/me/dash')).toBe(existingNew)
})
it('is idempotent — the second invocation is a no-op', () => {
localStorage.setItem(
'app-u/me/dash',
encodeLegacy({
grid: [],
fullscreen: false,
unusedInlineScripts: [],
hiddenInlineScripts: []
})
)
migrateLegacyUserDrafts('main')
expect(localStorage.getItem('userdraft/w/main/app/u/me/dash')).not.toBeNull()
// Drop the migrated entry to detect any re-migration attempt.
localStorage.removeItem('userdraft/w/main/app/u/me/dash')
// Drop the source too, so re-running couldn't even find a source.
// (The sentinel alone should be enough; this just clarifies the intent.)
migrateLegacyUserDrafts('main')
expect(localStorage.getItem('userdraft/w/main/app/u/me/dash')).toBeNull()
})
it('skips entirely when no workspace is available', () => {
localStorage.setItem(
'app-u/me/dash',
encodeLegacy({
grid: [],
fullscreen: false,
unusedInlineScripts: [],
hiddenInlineScripts: []
})
)
migrateLegacyUserDrafts('')
expect(localStorage.getItem('app-u/me/dash')).not.toBeNull()
})
it('handles malformed legacy payloads without throwing', () => {
localStorage.setItem('app-u/me/garbled', 'not-base64!!!')
expect(() => migrateLegacyUserDrafts('main')).not.toThrow()
// Migration didn't migrate, didn't crash — leaves the entry alone.
expect(localStorage.getItem('app-u/me/garbled')).toBe('not-base64!!!')
expect(localStorage.getItem('userdraft/w/main/flow/u/me/keep')).toBe(interim)
})
it('leaves keys whose path does not match the legacy `u|f/owner/name` shape alone', () => {
// A future feature or neighbouring code might pick a key like
// `app-recent` for its own purposes. The path doesn't look like a
// Windmill item path, so the migration must skip it.
// `app-recent` / `app-some_other_app` look like the legacy prefix but the
// suffix isn't a Windmill item path — a future feature might own them.
localStorage.setItem('app-recent', 'whatever')
localStorage.setItem('app-some_other_app', 'whatever')
// `flow-u/me/foo` matches the shape and would be migrated, but the
// payload also needs to look like a Windmill draft (asserted below).
localStorage.setItem('flow-u/me/foo', encodeLegacy({ flow: { value: { modules: [] } } }))
migrateLegacyUserDrafts('main')
purgeLegacyUserDrafts()
expect(localStorage.getItem('app-recent')).toBe('whatever')
expect(localStorage.getItem('app-some_other_app')).toBe('whatever')
expect(localStorage.getItem('userdraft/w/main/flow/u/me/foo')).not.toBeNull()
})
it('skips legacy-shaped keys whose payload does not look like a Windmill draft', () => {
// `app-u/me/dash` matches LEGACY_PATH_SHAPE and decodes to valid JSON,
// but none of the App-shape fields (grid/fullscreen/theme/
// unusedInlineScripts/hiddenInlineScripts) are present. Treat it as
// unrelated and leave it untouched.
const unrelated = encodeLegacy({ random: 'data', count: 7 })
localStorage.setItem('app-u/me/dash', unrelated)
it('leaves legacy-shaped keys whose payload does not look like a Windmill draft', () => {
// Matches LEGACY_PATH_SHAPE and decodes to valid JSON, but carries none of
// the App/flow draft fields — treat as unrelated, do not delete.
const unrelatedApp = encodeLegacy({ random: 'data', count: 7 })
localStorage.setItem('app-u/me/dash', unrelatedApp)
const unrelatedFlow = encodeLegacy({ stepsState: {} })
localStorage.setItem('flow-u/me/bar', unrelatedFlow)
migrateLegacyUserDrafts('main')
purgeLegacyUserDrafts()
expect(localStorage.getItem('app-u/me/dash')).toBe(unrelated)
expect(localStorage.getItem('userdraft/w/main/app/u/me/dash')).toBeNull()
expect(localStorage.getItem('app-u/me/dash')).toBe(unrelatedApp)
expect(localStorage.getItem('flow-u/me/bar')).toBe(unrelatedFlow)
expect(localStorage.getItem('userdraft/w/main/flow/u/me/bar')).toBeNull()
})
it('migrates multiple legacy entries in a single invocation', () => {
localStorage.setItem(
'app-u/me/a',
encodeLegacy({
grid: [],
fullscreen: false,
unusedInlineScripts: [],
hiddenInlineScripts: []
})
)
it('leaves a malformed (non-base64) legacy payload alone and does not throw', () => {
localStorage.setItem('app-u/me/garbled', 'not-base64!!!')
expect(() => purgeLegacyUserDrafts()).not.toThrow()
expect(localStorage.getItem('app-u/me/garbled')).toBe('not-base64!!!')
})
it('is idempotent — once the sentinel is set, a later legacy key survives', () => {
localStorage.setItem('app-u/me/a', encodeLegacy(legacyApp))
purgeLegacyUserDrafts()
expect(localStorage.getItem('app-u/me/a')).toBeNull()
// A key written after the first run is NOT swept (the sentinel short-circuits).
localStorage.setItem('app-u/me/b', encodeLegacy(legacyApp))
purgeLegacyUserDrafts()
expect(localStorage.getItem('app-u/me/b')).not.toBeNull()
})
it('purges multiple legacy entries in a single invocation', () => {
localStorage.setItem('app-u/me/a', encodeLegacy(legacyApp))
localStorage.setItem(
'flow-u/me/b',
encodeLegacy({ flow: { summary: '', value: { modules: [] }, path: 'u/me/b' } })
)
localStorage.setItem(
'rawapp-u/me/c',
encodeLegacy({ files: {}, runnables: {}, data: { tables: [] } })
encodeLegacy({ flow: { summary: '', value: { modules: [] } } })
)
localStorage.setItem('rawapp-u/me/c', encodeLegacy({ files: {}, runnables: {}, data: {} }))
migrateLegacyUserDrafts('main')
purgeLegacyUserDrafts()
expect(localStorage.getItem('userdraft/w/main/app/u/me/a')).not.toBeNull()
expect(localStorage.getItem('userdraft/w/main/flow/u/me/b')).not.toBeNull()
expect(localStorage.getItem('userdraft/w/main/raw_app/u/me/c')).not.toBeNull()
expect(localStorage.getItem('app-u/me/a')).toBeNull()
expect(localStorage.getItem('flow-u/me/b')).toBeNull()
expect(localStorage.getItem('rawapp-u/me/c')).toBeNull()
})
})
+32 -71
View File
@@ -1,22 +1,29 @@
/**
* One-off migration from the pre-UserDraft localStorage autosave entries to
* the workspace-scoped `userdraft/w/{ws}/{kind}/{path}` format.
* One-shot purge of the pre-UserDraft browser-local autosave keys.
*
* Legacy keys (global, not workspace-scoped assumed to belong to the user's
* current workspace at migration time):
* The original autosave (pre-#9121) wrote workspace-BLIND keys:
*
* `flow` / `flow-{path}` base64 of `encodeState({ flow, path, selectedId, draft_triggers, ... })`
* `app` / `app-{path}` base64 of `encodeState(App)`
* `rawapp` / `rawapp-{path}` base64 of `encodeState({ files, runnables, data })`
* `flow` / `flow-{path}` base64 of `encodeState({ flow, path, selectedId, draft_triggers, ... })`
* `app` / `app-{path}` base64 of `encodeState(App)`
* `rawapp` / `rawapp-{path}` base64 of `encodeState({ files, runnables, data })`
*
* Target keys: `userdraft/w/{workspace}/{flow|app|raw_app}/{path}` storing
* `JSON.stringify({ value: <transformed legacy value> })`.
* Neither the key nor the decoded value records a workspace (the value carries
* only workspace-agnostic item paths like `u/me/x`), so these drafts cannot be
* attributed to the workspace they were edited in. The current editors are
* DB-backed and never read these keys, so they are dead data with one dangerous
* property: promoting them to the DB would force a GUESS of the workspace, which
* mis-files drafts into whatever workspace happened to be active when the
* migration first ran (a single global sentinel gates it). We therefore drop
* them instead of migrating them.
*
* Only keys that BOTH match the legacy path shape AND decode to a plausible
* legacy draft are removed; unrelated look-alikes (`app-recent`, garbage,
* non-Windmill payloads) are left untouched. The workspace-scoped interim keys
* (`userdraft/w/{ws}/...`, written by the editor with the correct workspace)
* are NOT touched here `migrateUserDraftsToDb` still pushes those to the DB.
*
* Idempotent: writes a sentinel under `MIGRATION_FLAG` after the first run so
* subsequent invocations are no-ops. Existing new-format entries are never
* overwritten when both an old and a new entry exist for the same item, the
* old one is simply dropped on the assumption that the new entry is the more
* recent edit.
* subsequent invocations are no-ops.
*
* This file is intentionally standalone it does not import from
* `userDraft.svelte.ts` so the new code stays uncluttered by the legacy
@@ -71,10 +78,9 @@ function decodeLegacyState(raw: string): unknown {
* Per-kind shape gate. The legacy keys (`app-foo`, `flow-foo`, ...) are
* unusual enough that nothing else in the codebase has used them, but
* matching `LEGACY_PATH_SHAPE` doesn't prove the payload is actually a
* Windmill draft (any base64-of-JSON could pass). Promoting a stray payload
* would silently surface as a phantom "Restored from local storage" toast
* on the next edit, so we reject anything that doesn't carry the fields the
* legacy writers actually produced.
* Windmill draft (any base64-of-JSON could pass). We only delete keys we can
* positively recognise as legacy drafts, so a stray look-alike that happens to
* use this key shape is left untouched rather than silently dropped.
*/
function isPlausibleLegacyValue(kind: LegacyKind, decoded: unknown): boolean {
if (decoded == null || typeof decoded !== 'object') return false
@@ -103,32 +109,6 @@ function isPlausibleLegacyValue(kind: LegacyKind, decoded: unknown): boolean {
}
}
function transformLegacyValue(kind: LegacyKind, decoded: unknown): unknown {
const obj = decoded as Record<string, unknown>
switch (kind) {
case 'flow':
// The legacy bundle wrapped the Flow alongside view-state fields
// (selectedId, draft_triggers, ...). The new entry stores only the
// Flow — the view-state lives elsewhere or is re-derived.
return obj.flow
case 'app':
// Legacy stored the App directly.
return obj
case 'raw_app':
// Legacy bundle missed the `summary` field that the new editor adds.
return {
files: obj.files ?? {},
runnables: obj.runnables ?? {},
data: obj.data ?? {},
summary: typeof obj.summary === 'string' ? obj.summary : ''
}
}
}
function newKey(workspace: string, kind: LegacyKind, path: string): string {
return `userdraft/w/${workspace}/${kind}/${path}`
}
function listLocalStorageKeys(): string[] {
const out: string[] = []
for (let i = 0; i < localStorage.length; i++) {
@@ -139,16 +119,11 @@ function listLocalStorageKeys(): string[] {
}
/**
* Run the legacy new-format migration. Idempotent: returns immediately if a
* previous run completed (signalled by `MIGRATION_FLAG`).
*
* The migration is workspace-scoped because the legacy keys had no notion of
* workspace we treat the caller's current workspace as the owner of any
* surviving legacy entries.
* Remove the workspace-blind legacy autosave keys (see file header). Idempotent:
* returns immediately if a previous run completed (signalled by `MIGRATION_FLAG`).
*/
export function migrateLegacyUserDrafts(workspace: string): void {
export function purgeLegacyUserDrafts(): void {
if (typeof localStorage === 'undefined') return
if (!workspace) return
if (localStorage.getItem(MIGRATION_FLAG) !== null) return
try {
@@ -157,32 +132,18 @@ export function migrateLegacyUserDrafts(workspace: string): void {
if (!match) continue
const raw = localStorage.getItem(key)
if (raw == null) continue
try {
const decoded = decodeLegacyState(raw)
if (!isPlausibleLegacyValue(match.newKind, decoded)) continue
const value = transformLegacyValue(match.newKind, decoded)
const target = newKey(workspace, match.newKind, match.path)
if (value !== undefined && localStorage.getItem(target) == null) {
// `lastWrittenAt` makes the migrated entry visible to
// `gcUserDrafts`. We stamp it as "now" so a freshly-migrated
// autosave gets the full retention window — sweeping it
// immediately on the first GC pass would lose work the
// legacy migration just rescued.
localStorage.setItem(target, JSON.stringify({ value, lastWrittenAt: Date.now() }))
}
localStorage.removeItem(key)
} catch (e) {
console.error('UserDraft legacy migration: failed to migrate', key, e)
}
// Only drop keys we can positively recognise as legacy Windmill
// drafts; leave unrelated or unparseable look-alikes in place.
if (!isPlausibleLegacyValue(match.newKind, decodeLegacyState(raw))) continue
localStorage.removeItem(key)
}
localStorage.setItem(MIGRATION_FLAG, new Date().toISOString())
} catch (e) {
console.error('UserDraft legacy migration: aborted', e)
console.error('UserDraft legacy purge: aborted', e)
}
}
/** Test-only: clear the sentinel so the migration can re-run. */
/** Test-only: clear the sentinel so the purge can re-run. */
export function __resetUserDraftLegacyMigrationForTesting(): void {
try {
localStorage.removeItem(MIGRATION_FLAG)
+4 -2
View File
@@ -1190,8 +1190,10 @@ export function isCodeInjection(expr: string | undefined): boolean {
// app logic via the `query` context. Only params we actually own are listed
// here — the `wm_` prefix is a naming convention, not a reserved namespace, so
// we don't strip it wholesale (that would break apps reading their own `wm_*`
// params). `wm_coep` is a transport flag for cross-origin isolation headers.
export const WINDMILL_RESERVED_QUERY_PARAMS = new Set(['wm_coep'])
// params). `wm_coep` is a transport flag for cross-origin isolation headers;
// `wm_embed`/`wm_embedder_origin` are the opaque app viewer transport params
// (see PublicAppFrame).
export const WINDMILL_RESERVED_QUERY_PARAMS = new Set(['wm_coep', 'wm_embed', 'wm_embedder_origin'])
export function urlParamsToObject(
params: URLSearchParams,
@@ -58,7 +58,7 @@
import GlobalSearchModal from '$lib/components/search/GlobalSearchModal.svelte'
import MenuButton from '$lib/components/sidebar/MenuButton.svelte'
import { loadProtectionRules } from '$lib/workspaceProtectionRules.svelte'
import { migrateLegacyUserDrafts } from '$lib/userDraftLegacyMigration'
import { purgeLegacyUserDrafts } from '$lib/userDraftLegacyMigration'
import { migrateUserDraftsToDb } from '$lib/userDraftDbMigration'
import DraftMigrationErrorModal from '$lib/components/DraftMigrationErrorModal.svelte'
import { setContext, untrack } from 'svelte'
@@ -164,8 +164,9 @@
const toPath = navigation.to?.url.pathname
if (toPath && (toPath.startsWith('/apps_raw/add') || toPath.startsWith('/apps_raw/edit'))) {
const currentPath = navigation.from?.url.pathname
// Reload if we're not on an apps_raw path, or if we're on /apps/get_raw/ (viewing a raw app)
// The /apps/get_raw/ path doesn't have cross-origin isolation headers, so we need to reload
// Reload if we're not on an apps_raw path, or if we're on the raw app viewer
// (/apps_raw/get/): the viewer doesn't have cross-origin isolation headers, so
// we need a full reload to fetch them for the editor.
if (!currentPath?.startsWith('/apps_raw/') || currentPath?.startsWith('/apps_raw/get/')) {
navigation.cancel()
window.location.href = navigation.to!.url.href
@@ -435,16 +436,17 @@
$effect(() => {
$workspaceStore && untrack(() => onLoad())
})
// One-shot UserDraft migration chain. `migrateLegacyUserDrafts` folds
// the legacy `flow` / `app-…` / `rawapp-…` LS keys into the
// `userdraft/w/{ws}/{kind}/{path}` format; `migrateUserDraftsToDb`
// then pushes those onto the server-side draft table and clears LS
// on success. The order matters — the second step only sees what
// the first one normalized.
// One-shot UserDraft migration. `purgeLegacyUserDrafts` drops the oldest
// workspace-blind `flow` / `app-…` / `rawapp-…` LS autosave keys (they
// can't be attributed to a workspace, so promoting them would mis-file
// drafts). `migrateUserDraftsToDb` then pushes the workspace-scoped
// `userdraft/w/{ws}/{kind}/{path}` keys — written by the editor with the
// correct workspace — onto the server-side draft table, clearing LS on
// success.
$effect(() => {
if ($workspaceStore && $userStore) {
untrack(() => {
migrateLegacyUserDrafts($workspaceStore!)
purgeLegacyUserDrafts()
void migrateUserDraftsToDb()
})
}

Some files were not shown because too many files have changed in this diff Show More