From 9e6559a6f688cc8d982277b19920219ea6d0fd8e Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Tue, 2 Jun 2026 14:44:30 +0200 Subject: [PATCH 1/6] fix(nsjail): raise python download fd limit for --compile-bytecode (WIN-2009) (#9414) #9393 added `--compile-bytecode` to the uv pip install run inside the python download nsjail. uv spawns a Python interpreter that compiles .py files with parallelism scaling to the host CPU count, opening many file descriptors at once. The download nsjail capped `rlimit_nofile` at 64, which is exhausted on high-core machines, failing every install with "Failed to bytecode-compile ... Too many open files (os error 24)". Low-core VMs never hit the cap, so this surfaced only as a regression on larger workers after upgrading. Raise `rlimit_nofile` to 10000, matching the runtime configs (run.python3 / run.ansible) that already use that value. Fixes WIN-2009 Co-authored-by: Claude Opus 4.8 (1M context) --- backend/windmill-worker/nsjail/download.py.config.proto | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/backend/windmill-worker/nsjail/download.py.config.proto b/backend/windmill-worker/nsjail/download.py.config.proto index e56ef66de0..217fe5763a 100644 --- a/backend/windmill-worker/nsjail/download.py.config.proto +++ b/backend/windmill-worker/nsjail/download.py.config.proto @@ -8,7 +8,11 @@ time_limit: 900 rlimit_as: 2048 rlimit_cpu: 1000 rlimit_fsize: 1024 -rlimit_nofile: 64 +# uv's --compile-bytecode spawns a Python interpreter that compiles .py files +# with parallelism scaling to the host's CPU count, opening many fds at once. +# A low cap (was 64) is exhausted on high-core machines -> "Too many open files". +# Matches the runtime configs (run.python3/run.ansible) which already use 10000. +rlimit_nofile: 10000 envar: "HOME=/user" envar: "LD_LIBRARY_PATH=/usr/local/lib:$LD_LIBRARY_PATH" From ab2a15b2a859096eabde718bf6e60289ae187118 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Tue, 2 Jun 2026 14:47:45 +0200 Subject: [PATCH 2/6] fix(triggers): prevent Zoom challenge handler from being used as a signing oracle (#9413) The Zoom URL-validation challenge handler in `handle_challenge_request` would HMAC-sign any arbitrary `plainToken` and return the result. Since Zoom webhook verification checks `HMAC-SHA256(secret, "v0:{ts}:{body}")`, an attacker could craft a `plainToken` in that format to obtain a valid signature for a forged body, bypassing authentication on a later request. Unlike the Twitch handler, the Zoom handler verifies no signature on the challenge request (Zoom's protocol does not include one). Reject any `plainToken` containing `:` or longer than 128 chars: legitimate Zoom validation tokens are short random hex strings that never contain colons, while the exploit requires the colon-bearing `v0:{ts}:{body}` format. Fixes WIN-2008 Co-authored-by: Claude Opus 4.8 (1M context) --- .../src/http_trigger_auth.rs | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/backend/windmill-trigger-http/src/http_trigger_auth.rs b/backend/windmill-trigger-http/src/http_trigger_auth.rs index 19766cdbc2..10927d1ef9 100644 --- a/backend/windmill-trigger-http/src/http_trigger_auth.rs +++ b/backend/windmill-trigger-http/src/http_trigger_auth.rs @@ -337,6 +337,20 @@ mod zoom { return Ok(None); } + // Prevent this challenge endpoint from being used as a signing oracle. + // Legitimate Zoom validation tokens are short random hex strings that + // never contain colons. The exploit requires crafting a plainToken in the + // `v0:{timestamp}:{body}` webhook-signing format (always containing colons) + // to obtain a valid signature for an arbitrary body. Reject any token that + // does not look like a legitimate Zoom validation token. + if zoom_request_body.payload.plain_token.contains(':') + || zoom_request_body.payload.plain_token.len() > 128 + { + return Err(AuthenticationError::InvalidChallengeResponse( + "Zoom: invalid plainToken format".to_string(), + )); + } + let hmac_signature = calculate_hmac_signature( HmacAlgorithm::Sha256, &signature_config_data.secret_key, @@ -1540,6 +1554,52 @@ mod tests { assert!(response.is_none()); } + #[test] + fn test_zoom_challenge_normal_token_succeeds() { + // A legitimate Zoom validation token is a short random alphanumeric string. + let payload = r#"{"event":"endpoint.url_validation","event_ts":1234567890,"payload":{"plainToken":"qgg8vlvZRS6UYooatFL8Aw"}}"#; + + let handler = WebhookType::Zoom.get_webhook_handler().unwrap(); + let config_data = SignatureConfigData { secret_key: "zoom_secret" }; + let response = handler + .handle_challenge_request(&HeaderMap::new(), &config_data, payload) + .unwrap(); + assert!(response.is_some()); + } + + #[test] + fn test_zoom_challenge_token_with_colons_rejected() { + // Exploit attempt: a plainToken crafted in the `v0:{ts}:{body}` signing format + // would let an attacker obtain a valid webhook signature for an arbitrary body. + let payload = r#"{"event":"endpoint.url_validation","event_ts":1234567890,"payload":{"plainToken":"v0:1234567890:{\"forged\":\"body\"}"}}"#; + + let handler = WebhookType::Zoom.get_webhook_handler().unwrap(); + let config_data = SignatureConfigData { secret_key: "zoom_secret" }; + let result = handler.handle_challenge_request(&HeaderMap::new(), &config_data, payload); + assert!(matches!( + result, + Err(AuthenticationError::InvalidChallengeResponse(_)) + )); + } + + #[test] + fn test_zoom_challenge_token_too_long_rejected() { + // A plainToken exceeding 128 chars cannot be a legitimate Zoom validation token. + let long_token = "a".repeat(129); + let payload = format!( + r#"{{"event":"endpoint.url_validation","event_ts":1234567890,"payload":{{"plainToken":"{}"}}}}"#, + long_token + ); + + let handler = WebhookType::Zoom.get_webhook_handler().unwrap(); + let config_data = SignatureConfigData { secret_key: "zoom_secret" }; + let result = handler.handle_challenge_request(&HeaderMap::new(), &config_data, &payload); + assert!(matches!( + result, + Err(AuthenticationError::InvalidChallengeResponse(_)) + )); + } + // --- Custom webhook end-to-end --- #[test] From 2bff250f89beeb06025bd6edca478492695f8d42 Mon Sep 17 00:00:00 2001 From: Guilhem Date: Tue, 2 Jun 2026 14:58:21 +0200 Subject: [PATCH 3/6] feat(frontend): harmonize diff button placement in script and raw app editors (#9410) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(frontend): harmonize diff button placement across editors Co-Authored-By: Claude Opus 4.8 (1M context) * refactor(frontend): address review nits — drop unused diffDrawer param, fix stale comments Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .../src/lib/components/ScriptBuilder.svelte | 77 +++++++++++-------- .../raw_apps/RawAppEditorHeader.svelte | 62 ++++++++------- .../sessions/ScriptEditorView.svelte | 2 +- 3 files changed, 79 insertions(+), 62 deletions(-) diff --git a/frontend/src/lib/components/ScriptBuilder.svelte b/frontend/src/lib/components/ScriptBuilder.svelte index 9dc36e3503..8f4e75ff3d 100644 --- a/frontend/src/lib/components/ScriptBuilder.svelte +++ b/frontend/src/lib/components/ScriptBuilder.svelte @@ -55,6 +55,7 @@ Bug, CheckCircle, Code, + DiffIcon, EllipsisVertical, Plus, Rocket, @@ -101,7 +102,6 @@ import DraftTriggersConfirmationModal from './common/confirmationModal/DraftTriggersConfirmationModal.svelte' import { Triggers } from './triggers/triggers.svelte' import type { ScriptBuilderProps } from './script_builder' - import type { DiffDrawerI } from './diff_drawer' import WorkerTagSelect from './WorkerTagSelect.svelte' import type { ButtonType } from './common/button/model' import DebounceLimit from './flows/DebounceLimit.svelte' @@ -804,13 +804,34 @@ // Inside an AI session pane (which injects an aiChatManager via context) the // extra deploy-dropdown options — Deploy & Stay here, Fork, Edit in workspace // fork, Exit & See details, Export — don't make sense: the session always - // stays put and is already scoped to a fork. Only "Show diff" is kept. + // stays put and is already scoped to a fork. Diff is exposed as a standalone + // top-bar button (rendered independently of the session pane), not here. const inSessionPane = !!getContext('aiChatManager') + async function openDiffDrawer() { + if (!savedScript) { + return + } + await syncWithDeployed() + + const currentDraftTriggers = structuredClone(triggersState.getDraftTriggersSnapshot()) + + const deployed = deployedValue ?? savedScript + const current = { ...script, draft_triggers: currentDraftTriggers } + if (current.assets && !current.assets.length) delete current.assets + + diffDrawer?.openDrawer() + diffDrawer?.setDiff({ + mode: 'normal', + deployed, + draft: savedScript['draft'], + current + }) + } + function computeDropdownItems( initialPath: string, - savedScript: NewScriptWithDraftAndDraftTriggers | undefined, - diffDrawer: DiffDrawerI | undefined + savedScript: NewScriptWithDraftAndDraftTriggers | undefined ) { let dropdownItems: { label: string; onClick: () => void }[] = initialPath != '' && customUi?.topBar?.extraDeployOptions != false @@ -841,35 +862,6 @@ : []) ] : []), - ...(customUi?.topBar?.diff !== false && savedScript && diffDrawer - ? [ - { - label: 'Show diff', - onClick: async () => { - if (!savedScript) { - return - } - await syncWithDeployed() - - const currentDraftTriggers = structuredClone( - triggersState.getDraftTriggersSnapshot() - ) - - const deployed = deployedValue ?? savedScript - const current = { ...script, draft_triggers: currentDraftTriggers } - if (current.assets && !current.assets.length) delete current.assets - - diffDrawer?.openDrawer() - diffDrawer?.setDiff({ - mode: 'normal', - deployed, - draft: savedScript['draft'], - current - }) - } - } - ] - : []), ...(!inSessionPane && !script.draft_only && script.kind === 'script' && @@ -2035,6 +2027,21 @@ {/if} {/snippet} + {#snippet diffButton()} + {#if customUi?.topBar?.diff != false} + + {/if} + {/snippet} {#if compactTopbar} {#snippet buttonReplacement()} @@ -2048,8 +2055,10 @@ /> {/snippet} + {@render diffButton()} {@render settingsButton()} {:else} + {@render diffButton()} {#if customUi?.topBar?.tagEdit != false} {#if $workerTags} {#if $workerTags?.length ?? 0 > 0} @@ -2080,7 +2089,7 @@ handleEditScript(false, detail)} /> diff --git a/frontend/src/lib/components/raw_apps/RawAppEditorHeader.svelte b/frontend/src/lib/components/raw_apps/RawAppEditorHeader.svelte index 7f9e8e9807..dedb7b5d11 100644 --- a/frontend/src/lib/components/raw_apps/RawAppEditorHeader.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppEditorHeader.svelte @@ -364,6 +364,29 @@ }) } + async function openDiffDrawer() { + if (!savedApp) { + return + } + + // deployedValue should be syncronized when we open Diff + await syncWithDeployed() + + diffDrawer?.openDrawer() + diffDrawer?.setDiff({ + mode: 'normal', + deployed: deployedValue ?? savedApp, + draft: savedApp.draft, + current: { + summary: summary, + value: app, + path: newEditedPath || savedApp.draft?.path || savedApp.path, + policy, + custom_path: customPath + } + }) + } + async function updateApp(npath: string) { if (!app) { sendUserToast(`App hasn't been loaded yet`, true) @@ -682,33 +705,6 @@ action: () => { publishToHubDrawerOpen = true } - }, - { - displayName: 'Diff', - icon: DiffIcon, - action: async () => { - if (!savedApp) { - return - } - - // deployedValue should be syncronized when we open Diff - await syncWithDeployed() - - diffDrawer?.openDrawer() - diffDrawer?.setDiff({ - mode: 'normal', - deployed: deployedValue ?? savedApp, - draft: savedApp.draft, - current: { - summary: summary, - value: app, - path: newEditedPath || savedApp.draft?.path || savedApp.path, - policy, - custom_path: customPath - } - }) - }, - disabled: !savedApp } ]) @@ -965,6 +961,18 @@ {/snippet} + +
{:else} diff --git a/frontend/src/lib/components/DisplayResultControlBar.svelte b/frontend/src/lib/components/DisplayResultControlBar.svelte index d45ca3f92d..45011d2db1 100644 --- a/frontend/src/lib/components/DisplayResultControlBar.svelte +++ b/frontend/src/lib/components/DisplayResultControlBar.svelte @@ -4,6 +4,7 @@ import Popover from './Popover.svelte' import { copyToClipboard } from '$lib/utils' import { downloadViaClient, shouldDownloadViaClient } from '$lib/utils/downloadFile' + import { appendViewToken } from '$lib/viewToken' import type { DisplayResultUi } from './custom_ui' import { createEventDispatcher } from 'svelte' @@ -41,9 +42,11 @@ let resultApiPath = $derived( workspaceId && jobId - ? nodeId - ? `/w/${workspaceId}/jobs/result_by_id/${jobId}/${nodeId}` - : `/w/${workspaceId}/jobs_u/completed/get_result/${jobId}` + ? appendViewToken( + nodeId + ? `/w/${workspaceId}/jobs/result_by_id/${jobId}/${nodeId}` + : `/w/${workspaceId}/jobs_u/completed/get_result/${jobId}` + ) : undefined ) let downloadName = $derived(`${filename ?? 'result'}.json`) diff --git a/frontend/src/lib/components/FlowStatusViewerInner.svelte b/frontend/src/lib/components/FlowStatusViewerInner.svelte index 809eff58d9..d0bf66d61b 100644 --- a/frontend/src/lib/components/FlowStatusViewerInner.svelte +++ b/frontend/src/lib/components/FlowStatusViewerInner.svelte @@ -28,6 +28,7 @@ import ModuleStatus from './ModuleStatus.svelte' import { clone, isScriptPreview, msToSec, readFieldsRecursively, truncateRev } from '$lib/utils' import { downloadViaClient, shouldDownloadViaClient } from '$lib/utils/downloadFile' + import { appendViewToken } from '$lib/viewToken' import JobArgs from './JobArgs.svelte' import { ChevronDown, Download, ExternalLink, Hourglass } from 'lucide-svelte' import { deepEqual } from 'fast-equals' @@ -1839,7 +1840,9 @@ style="min-height: {minTabHeight}px" > {#if !hideDownloadLogs && !isReplay && job?.id} - {@const logsApiPath = `/w/${workspace}/jobs_u/get_flow_all_logs/${job.id}`} + {@const logsApiPath = appendViewToken( + `/w/${workspace}/jobs_u/get_flow_all_logs/${job.id}` + )} {@const logsName = `windmill_flow_logs_${job.id}.txt`}
{#if shouldDownloadViaClient()} diff --git a/frontend/src/lib/components/JobArgs.svelte b/frontend/src/lib/components/JobArgs.svelte index 77c94de11e..ffa90bb941 100644 --- a/frontend/src/lib/components/JobArgs.svelte +++ b/frontend/src/lib/components/JobArgs.svelte @@ -14,6 +14,7 @@ import { deepEqual } from 'fast-equals' import { isWindmillTooBigObject } from './job_args' import { downloadViaClient, shouldDownloadViaClient } from '$lib/utils/downloadFile' + import { appendViewToken } from '$lib/viewToken' interface Props { id?: string | undefined @@ -29,7 +30,9 @@ let jsonStr = $state('') const argsDownloadName = 'windmill-args.json' - let argsApiPath = $derived(id && workspace ? `/w/${workspace}/jobs_u/get_args/${id}` : undefined) + let argsApiPath = $derived( + id && workspace ? appendViewToken(`/w/${workspace}/jobs_u/get_args/${id}`) : undefined + ) let argsDataHref = $derived(`data:text/json;charset=utf-8,${encodeURIComponent(jsonStr)}`) function pythonCode() { diff --git a/frontend/src/lib/components/JobLoader.svelte b/frontend/src/lib/components/JobLoader.svelte index 7913fd4be7..1aa24e68a5 100644 --- a/frontend/src/lib/components/JobLoader.svelte +++ b/frontend/src/lib/components/JobLoader.svelte @@ -15,6 +15,7 @@ type OpenFlow } from '$lib/gen' import { workspaceStore } from '$lib/stores' + import { getViewToken } from '$lib/viewToken' import { WM_LOGS_SKIPPED } from '$lib/consts' import { getContext, onDestroy, tick, untrack } from 'svelte' import type { SupportedLanguage } from '$lib/common' @@ -47,6 +48,9 @@ noLogs?: boolean workspaceOverride?: string | undefined notfound?: boolean + /** Status/body of the last load failure, so callers can distinguish e.g. a + * 403 (job exists but no access — offer a share link) from a 404. */ + loadError?: { status?: number; message?: string } | undefined allowConcurentRequests?: boolean jobUpdateLastFetch?: Date | undefined toastError?: boolean @@ -65,6 +69,7 @@ allowConcurentRequests = false, workspaceOverride = undefined, notfound = $bindable(false), + loadError = $bindable(undefined), jobUpdateLastFetch = $bindable(undefined), toastError = false, onlyResult = false, @@ -600,9 +605,14 @@ } } notfound = false + loadError = undefined } catch (err) { + const status = (err as any)?.status + loadError = { status, message: (err as any)?.body ?? (err as any)?.message } errorIteration += 1 - if (errorIteration == 5) { + // Auth failures won't resolve by retrying: surface them immediately so + // the caller can show the right message (e.g. 403 -> request a share link). + if (status === 403 || status === 404 || errorIteration == 5) { notfound = true job = undefined clearCurrentId() @@ -754,6 +764,13 @@ params.set('token', token.token) } + // Share read link: SSE/EventSource can't set the X-View-Token header, + // so carry the token as a query param instead. + const viewToken = getViewToken() + if (viewToken) { + params.set('view_token', viewToken) + } + const sseUrl = `/api/w/${workspace}/jobs_u/getupdate_sse/${id}?${params.toString()}` currentEventSource = new EventSource(sseUrl) diff --git a/frontend/src/lib/components/LogViewer.svelte b/frontend/src/lib/components/LogViewer.svelte index ca90e3968f..b5a31265be 100644 --- a/frontend/src/lib/components/LogViewer.svelte +++ b/frontend/src/lib/components/LogViewer.svelte @@ -17,6 +17,7 @@ import { base } from '$lib/base' import { withExternalDomain } from '$lib/externalDomain' import { downloadViaClient, shouldDownloadViaClient } from '$lib/utils/downloadFile' + import { appendViewToken } from '$lib/viewToken' import { workspaceStore } from '$lib/stores' import { AnsiUp } from 'ansi_up' import NoWorkerWithTagWarning from './runs/NoWorkerWithTagWarning.svelte' @@ -241,7 +242,7 @@ fetchedSkippedJobId = undefined } }) - let logsApiPath = $derived(`/w/${$workspaceStore}/jobs_u/get_logs/${jobId}`) + let logsApiPath = $derived(appendViewToken(`/w/${$workspaceStore}/jobs_u/get_logs/${jobId}`)) let downloadHref = $derived(withExternalDomain(`${base}/api${logsApiPath}`)) let downloadName = $derived(`windmill_logs_${jobId}.txt`) let truncatedContent = $derived( diff --git a/frontend/src/lib/viewToken.ts b/frontend/src/lib/viewToken.ts new file mode 100644 index 0000000000..18c21d27d8 --- /dev/null +++ b/frontend/src/lib/viewToken.ts @@ -0,0 +1,44 @@ +import { OpenAPI } from '$lib/gen' + +/** + * Share-read-link support. When viewing a run via a share link + * (`/run/{id}?view_token=...`), the token grants the current authenticated member + * read access to that job and its flow subtree on the backend. + * + * The token is attached to every generated-client request via the `X-View-Token` + * header (registered once below) so we don't have to thread it through every + * `JobService` call. `EventSource`/SSE can't set headers, so those URLs read + * `getViewToken()` and append it as a `view_token` query param instead. + */ +let currentViewToken: string | undefined = undefined + +export function setViewToken(token: string | undefined): void { + currentViewToken = token || undefined +} + +export function getViewToken(): string | undefined { + return currentViewToken +} + +/** + * Append the current view token as a `view_token` query param to a URL/path. + * Used for download links (plain `` and `downloadViaClient`), which don't + * go through the request interceptor that adds the `X-View-Token` header. + * Returns the url unchanged when no share link is active. + */ +export function appendViewToken(url: string): string { + if (!currentViewToken) return url + const sep = url.includes('?') ? '&' : '?' + return `${url}${sep}view_token=${encodeURIComponent(currentViewToken)}` +} + +// Register the request interceptor exactly once. It is a no-op unless a view token +// is currently set, so it is safe to keep installed for the whole session. +OpenAPI.interceptors.request.use((options) => { + if (currentViewToken) { + const headers = new Headers(options.headers) + headers.set('X-View-Token', currentViewToken) + options.headers = headers + } + return options +}) diff --git a/frontend/src/routes/(root)/(logged)/run/[...run]/+page.svelte b/frontend/src/routes/(root)/(logged)/run/[...run]/+page.svelte index 05b53ece63..1357038c39 100644 --- a/frontend/src/routes/(root)/(logged)/run/[...run]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/run/[...run]/+page.svelte @@ -36,7 +36,8 @@ ClipboardCopy, GitBranch, GitFork, - EllipsisVertical + EllipsisVertical, + Share2 } from 'lucide-svelte' import DisplayResult from '$lib/components/DisplayResult.svelte' @@ -85,6 +86,7 @@ } from '$lib/components/flows/FlowAssetsHandler.svelte' import JobAssetsViewer from '$lib/components/assets/JobAssetsViewer.svelte' import { page } from '$app/state' + import { setViewToken } from '$lib/viewToken' import { twMerge } from 'tailwind-merge' import FlowRestartButton from '$lib/components/FlowRestartButton.svelte' import { useNestedRestartState } from '$lib/components/useNestedRestartState.svelte' @@ -120,6 +122,7 @@ let testIsLoading = $state(false) let jobLoader: JobLoader | undefined = $state(undefined) + let loadError: { status?: number; message?: string } | undefined = $state(undefined) // Flow execution status state let suspendStatus: import('$lib/utils').StateStore> = @@ -146,6 +149,34 @@ concurrencyKey = await ConcurrencyGroupsService.getConcurrencyKey({ id: job.id }) } + // Share read link: if the URL carries a `view_token`, install it so every job + // read on this page (incl. flow steps, args, logs, SSE) is authorized by it. + // Set eagerly at init (before JobLoader mounts and fires its first fetch), and + // reactively keep it in sync across client-side navigation. + setViewToken(page.url.searchParams.get('view_token') ?? undefined) + $effect(() => { + setViewToken(page.url.searchParams.get('view_token') ?? undefined) + }) + onDestroy(() => setViewToken(undefined)) + + async function shareReadLink(id: string): Promise { + try { + const workspace = $workspaceStore! + const token = (await JobService.getJobViewToken({ workspace, id })).trim() + // Pin the workspace in the link: the token is signed with this workspace's + // key, and the logged layout only switches `$workspaceStore` when the URL + // carries `workspace=`. Without it a recipient whose active workspace + // differs would open the run (and validate the token) against the wrong one. + const url = `${window.location.origin}${base}/run/${id}?workspace=${encodeURIComponent( + workspace + )}&view_token=${encodeURIComponent(token)}` + copyToClipboard(url) + sendUserToast('Read-only share link copied to clipboard') + } catch (e) { + sendUserToast(`Failed to create share link: ${e}`, true) + } + } + async function deleteCompletedJob(id: string): Promise { await JobService.deleteCompletedJob({ workspace: $workspaceStore!, id }) getJob() @@ -447,6 +478,7 @@ bind:jobUpdateLastFetch workspaceOverride={$workspaceStore} bind:notfound + bind:loadError /> {/if} @@ -454,7 +486,28 @@ -{#if notfound || (job?.workspace_id != undefined && $workspaceStore != undefined && job?.workspace_id != $workspaceStore)} +{#if loadError?.status === 403} +
+
+ +
+

+ This run exists in {$workspaceStore}, but you don't + have permission to view it. +

+

+ Ask a colleague who can see it to open the run and use the + Share button to send you a read-only link. Opening that + link will grant you access to this run (and its steps). +

+
+
+
+ +
+
+
+{:else if notfound || (job?.workspace_id != undefined && $workspaceStore != undefined && job?.workspace_id != $workspaceStore)}

{/if} {/if} + {#if job} + + {/if} {@const stem = job?.job_kind === 'script_hub' ? '/scripts' : `/${job?.job_kind}s`} {@const viewHref = `${stem}/get/${isScript ? job?.script_hash : job?.script_path}`} {#if (job?.job_kind == 'flow' || isFlowPreview(job?.job_kind)) && job?.['running'] && job?.parent_job == undefined}