diff --git a/backend/windmill-api/src/static_assets.rs b/backend/windmill-api/src/static_assets.rs index 35728bc693..06a79d022e 100644 --- a/backend/windmill-api/src/static_assets.rs +++ b/backend/windmill-api/src/static_assets.rs @@ -50,8 +50,15 @@ const TWO_HUNDRED: &str = "200.html"; /// Check if the original path requires cross-origin isolation headers. /// -/// These headers are needed for SharedArrayBuffer and TypeScript workers -/// (raw app editor at `/apps_raw/`, in-browser bundler at `/ui_builder/`). +/// CANONICAL COEP RATIONALE (the dev-server mirror in `frontend/vite.config.js` +/// and the navigation guards in `frontend/src/routes/(root)/(logged)/+layout.svelte` +/// point here): the headers are needed for SharedArrayBuffer and TypeScript +/// workers (raw app editor at `/apps_raw/edit|add`, in-browser bundler at +/// `/ui_builder/`). The raw app *viewer* (`/apps_raw/get/`) must NOT get them: +/// COEP `require-corp` blocks the viewed app's cross-origin subresources +/// (external images, embeds) that lack CORP — and since headers stick to the +/// document, apps would break on a page reload while working when reached via +/// client-side navigation. /// /// Public apps (`/public/` and custom paths `/a/`) opt in via the `wm_coep` /// query param: a public (raw) app must set COEP to be embeddable as an iframe @@ -62,7 +69,9 @@ const TWO_HUNDRED: &str = "200.html"; /// explicitly requests it. #[cfg(feature = "static_frontend")] fn needs_cross_origin_isolation(original_path: &str, query: Option<&str>) -> bool { - original_path.starts_with("/apps_raw/") + // no trailing slash on edit/add: matches the +layout.svelte guards + original_path.starts_with("/apps_raw/edit") + || original_path.starts_with("/apps_raw/add") || original_path.starts_with("/ui_builder/") || ((original_path.starts_with("/public/") || original_path.starts_with("/a/")) && query_has_flag(query, "wm_coep")) @@ -152,8 +161,15 @@ mod tests { fn test_needs_cross_origin_isolation() { // editor + bundler are always isolated, regardless of query assert!(needs_cross_origin_isolation("/apps_raw/edit/foo", None)); + assert!(needs_cross_origin_isolation("/apps_raw/add", None)); assert!(needs_cross_origin_isolation("/ui_builder/index.html", None)); + // the raw app viewer must NOT be isolated + assert!(!needs_cross_origin_isolation( + "/apps_raw/get/u/foo/bar", + None + )); + // public apps (and custom paths) are isolated only when they opt in via wm_coep assert!(needs_cross_origin_isolation( "/public/ws/secret", diff --git a/frontend/src/lib/components/copilot/chat/flow/FlowAIChat.svelte b/frontend/src/lib/components/copilot/chat/flow/FlowAIChat.svelte index 1c6376bc62..e8e3207e05 100644 --- a/frontend/src/lib/components/copilot/chat/flow/FlowAIChat.svelte +++ b/frontend/src/lib/components/copilot/chat/flow/FlowAIChat.svelte @@ -197,7 +197,15 @@ return { errorCount: 0, warningCount: 0, errors: [], warnings: [] } }, - setFlowJson: async ({ modules, schema, preprocessorModule, failureModule, groups, notes }) => { + setFlowJson: async ({ + modules, + schema, + preprocessorModule, + failureModule, + groups, + notes, + settings + }) => { try { if ( modules !== undefined || @@ -205,7 +213,8 @@ preprocessorModule !== undefined || failureModule !== undefined || groups !== undefined || - notes !== undefined + notes !== undefined || + settings !== undefined ) { // Take snapshot of current flowStore and set as beforeFlow if (!diffManager?.hasPendingChanges) { @@ -221,7 +230,8 @@ preprocessorModule, failureModule, groups, - notes + notes, + settings }) // Refresh the state store to update UI diff --git a/frontend/src/lib/components/copilot/chat/flow/core.ts b/frontend/src/lib/components/copilot/chat/flow/core.ts index 2e6fea35a7..98f35cd3a9 100644 --- a/frontend/src/lib/components/copilot/chat/flow/core.ts +++ b/frontend/src/lib/components/copilot/chat/flow/core.ts @@ -43,10 +43,13 @@ import { flowModuleSchema } from './openFlowZod.gen' import { collectAllFlowModuleIdsFromModules } from '$lib/components/flows/flowTree' import { buildEditableFlowJson as buildEditableFlowJsonBase, + FLOW_VALUE_SETTINGS_KEYS, + pickFlowValueSettings, validateEditableFlowJson, validateFlowModules, validateFlowSchema, - type EditableFlowJson + type EditableFlowJson, + type FlowValueSettings } from './editableFlowJson' import { FLOW_CHAT_SPECIAL_MODULES, getFlowPrompt } from '$system_prompts' @@ -57,6 +60,9 @@ type FlowJsonUpdate = { failureModule?: FlowModule | null groups?: FlowGroup[] | null notes?: FlowNote[] | null + /** Full state of the top-level FlowValue settings: when provided, keys + * absent from it are removed from the flow value. */ + settings?: FlowValueSettings } function formatEmptyInlineScriptWarning({ @@ -563,7 +569,8 @@ export const flowTools: Tool[] = [ preprocessorModule: parsedFlow.preprocessor_module, failureModule: parsedFlow.failure_module, groups: parsedFlow.groups, - notes: parsedFlow.notes + notes: parsedFlow.notes, + settings: pickFlowValueSettings(parsedFlow) }) const warning = formatEmptyInlineScriptWarning(updateResult) @@ -854,7 +861,7 @@ export function prepareFlowSystemMessage(customPrompt?: string): ChatCompletionS Use \`patch_flow_json\` for small, localized changes when you can target an exact snippet from the \`CURRENT FLOW JSON COMPACT\` block below. Always copy the exact search text from the \`CURRENT FLOW JSON COMPACT\` block below. -The compact JSON is a single object with \`modules\`, \`schema\`, \`preprocessor_module\`, \`failure_module\`, \`groups\`, and \`notes\` keys. +The compact JSON is a single object with \`modules\`, \`schema\`, \`preprocessor_module\`, \`failure_module\`, \`groups\`, and \`notes\` keys, plus any top-level flow settings that are set (${FLOW_VALUE_SETTINGS_KEYS.join(', ')}). Settings can be added, edited, or removed with \`patch_flow_json\` as top-level keys — e.g. \`chat_input_enabled: true\` marks the flow as chat-style (flow-as-chat); keep it intact when restructuring such a flow. \`set_flow_json\` never changes these settings. **Parameters:** - \`old_string\`: Exact JSON text to find diff --git a/frontend/src/lib/components/copilot/chat/flow/editableFlowJson.test.ts b/frontend/src/lib/components/copilot/chat/flow/editableFlowJson.test.ts new file mode 100644 index 0000000000..9a106f5131 --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/flow/editableFlowJson.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it, vi } from 'vitest' +import type { FlowModule, FlowValue } from '$lib/gen' +import { + applyEditableFlowJsonToFlow, + buildEditableFlowJson, + validateEditableFlowJson +} from './editableFlowJson' +import { createInlineScriptSession } from './inlineScriptsUtils' + +vi.mock('../shared', () => ({ + SPECIAL_MODULE_IDS: { + PREPROCESSOR: 'preprocessor', + FAILURE: 'failure' + } +})) + +function makeRawScriptModule(id: string, content: string): FlowModule { + return { + id, + summary: id, + value: { + type: 'rawscript', + language: 'bun', + content, + input_transforms: {} + } + } as FlowModule +} + +function makeFlowValue(extra: Record = {}): FlowValue { + return { + modules: [makeRawScriptModule('step_a', 'code a')], + ...extra + } as FlowValue +} + +describe('flow settings in the compact editable view', () => { + it('round-trips top-level flow settings through build → validate → apply', () => { + const value = makeFlowValue({ chat_input_enabled: true, same_worker: true }) + const session = createInlineScriptSession() + const editable = buildEditableFlowJson({ value }, session) + + expect(editable.chat_input_enabled).toBe(true) + expect(editable.same_worker).toBe(true) + + const revalidated = validateEditableFlowJson(JSON.parse(JSON.stringify(editable))) + const result = applyEditableFlowJsonToFlow(value, revalidated, session) + + expect(result.chat_input_enabled).toBe(true) + expect(result.same_worker).toBe(true) + expect(result.modules[0]?.value).toMatchObject({ type: 'rawscript', content: 'code a' }) + }) + + it('applies a patched-in setting and deletes a removed one', () => { + const value = makeFlowValue({ cache_ttl: 60 }) + const session = createInlineScriptSession() + const editable = buildEditableFlowJson({ value }, session) + + const patched = JSON.parse(JSON.stringify(editable)) + patched.chat_input_enabled = true + delete patched.cache_ttl + + const result = applyEditableFlowJsonToFlow(value, validateEditableFlowJson(patched), session) + + expect(result.chat_input_enabled).toBe(true) + expect('cache_ttl' in result).toBe(false) + }) + + it('preserves original FlowValue fields outside the compact view', () => { + const value = makeFlowValue({ some_future_field: 'kept' }) + const session = createInlineScriptSession() + const editable = buildEditableFlowJson({ value }, session) + + expect('some_future_field' in editable).toBe(false) + + const result = applyEditableFlowJsonToFlow( + value, + validateEditableFlowJson(JSON.parse(JSON.stringify(editable))), + session + ) + + expect((result as Record).some_future_field).toBe('kept') + }) + + it('rejects unknown top-level keys instead of silently dropping them', () => { + expect(() => + validateEditableFlowJson({ + modules: [makeRawScriptModule('step_a', 'code a')], + chat_enabled: true + }) + ).toThrow(/Unknown top-level flow key\(s\): chat_enabled/) + }) + + it('rejects settings with the wrong type', () => { + expect(() => + validateEditableFlowJson({ + modules: [makeRawScriptModule('step_a', 'code a')], + chat_input_enabled: 'yes' + }) + ).toThrow(/chat_input_enabled/) + }) +}) diff --git a/frontend/src/lib/components/copilot/chat/flow/editableFlowJson.ts b/frontend/src/lib/components/copilot/chat/flow/editableFlowJson.ts index e920d4bdfa..1258f94a93 100644 --- a/frontend/src/lib/components/copilot/chat/flow/editableFlowJson.ts +++ b/frontend/src/lib/components/copilot/chat/flow/editableFlowJson.ts @@ -12,6 +12,18 @@ import { type FlowNote } from './helperUtils' import { flowModuleSchema, flowModulesSchema } from './openFlowZod.gen' +import { + FLOW_VALUE_SETTINGS_KEYS, + flowValueSettingsSchema, + pickFlowValueSettings, + type FlowValueSettings +} from './flowValueSettings' + +export { + FLOW_VALUE_SETTINGS_KEYS, + pickFlowValueSettings, + type FlowValueSettings +} from './flowValueSettings' /** * Compact, agent-friendly representation of a flow. @@ -27,7 +39,7 @@ export type EditableFlowJson = { failure_module: FlowModule | null groups: FlowGroup[] | null notes: FlowNote[] | null -} +} & FlowValueSettings /** Optional input to the rich-error path of `validateEditableFlowJson`. */ type SchemaErrorContext = { @@ -279,10 +291,19 @@ function validateOptionalFlowModule(rawModule: unknown, fieldName: string): Flow return result.data } +export const EDITABLE_FLOW_STRUCTURAL_KEYS = [ + 'modules', + 'schema', + 'preprocessor_module', + 'failure_module', + 'groups', + 'notes' +] as const + /** * Parse and validate a raw object as an `EditableFlowJson`. Validates module * shape, schema shape, optional special modules (with their reserved ids), - * groups, and that no module ids collide. + * groups, top-level flow settings, and that no module ids collide. */ export function validateEditableFlowJson( rawFlow: unknown, @@ -293,6 +314,28 @@ export function validateEditableFlowJson( } const flow = rawFlow as Record + + // Reject unknown top-level keys: silently dropping them would make patch + // tools report success for edits that never land on the flow. + const allowedKeys = new Set([ + ...EDITABLE_FLOW_STRUCTURAL_KEYS, + ...FLOW_VALUE_SETTINGS_KEYS + ]) + const unknownKeys = Object.keys(flow).filter((key) => !allowedKeys.has(key)) + if (unknownKeys.length > 0) { + throw new Error( + `Unknown top-level flow key(s): ${unknownKeys.join(', ')}. Allowed keys: ${[...allowedKeys].join(', ')}` + ) + } + + const settingsResult = flowValueSettingsSchema.safeParse(flow) + if (!settingsResult.success) { + const issue = settingsResult.error.issues[0] + const path = issue?.path?.join('.') ?? 'settings' + throw new Error(`Invalid flow setting ${path}: ${issue?.message ?? 'unknown error'}`) + } + const settings = pickFlowValueSettings(settingsResult.data) + const modules = validateFlowModules(flow.modules, ctx) const schema = validateFlowSchema(flow.schema) const preprocessorModule = validateOptionalFlowModule( @@ -345,7 +388,8 @@ export function validateEditableFlowJson( preprocessor_module: preprocessorModule, failure_module: failureModule, groups, - notes + notes, + ...settings } } @@ -401,7 +445,8 @@ export function buildEditableFlowJson( preprocessor_module: preprocessorModule ?? null, failure_module: failureModule ?? null, groups: flow.value.groups ?? null, - notes: flow.value.notes ?? null + notes: flow.value.notes ?? null, + ...pickFlowValueSettings(flow.value) } } @@ -420,8 +465,12 @@ export function restoreSpecialRawscriptModule( /** * Inverse of `buildEditableFlowJson`. Replaces `inline_script.` * placeholders in `editable.modules` and the special modules with the content - * stored in `session`. Other fields on the original FlowValue (`same_worker`, - * `concurrent_limit`, etc.) are preserved. + * stored in `session`. + * + * The compact view is the full state for the settings in + * `FLOW_VALUE_SETTINGS_KEYS`: a settings key absent from `editable` is removed + * from the result, so patches can unset them. Fields of the original FlowValue + * outside that list are preserved untouched. * * Pair with `buildEditableFlowJson` for round-trip patches: extract → patch * the compact view → restore. @@ -431,7 +480,7 @@ export function applyEditableFlowJsonToFlow( editable: EditableFlowJson, session: InlineScriptSession ): FlowValue { - return { + const result: FlowValue = { ...originalValue, modules: session.restoreInlineScriptReferences(editable.modules), preprocessor_module: @@ -440,6 +489,14 @@ export function applyEditableFlowJsonToFlow( groups: editable.groups ?? undefined, notes: editable.notes ?? undefined } + for (const key of FLOW_VALUE_SETTINGS_KEYS) { + if (editable[key] !== undefined) { + ;(result as Record)[key] = editable[key] + } else { + delete (result as Record)[key] + } + } + return result } /** diff --git a/frontend/src/lib/components/copilot/chat/flow/flowValueSettings.ts b/frontend/src/lib/components/copilot/chat/flow/flowValueSettings.ts new file mode 100644 index 0000000000..a660bf1ae3 --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/flow/flowValueSettings.ts @@ -0,0 +1,52 @@ +import { z } from 'zod' +import type { FlowValue } from '$lib/gen' + +/** + * Non-structural top-level FlowValue settings exposed in the compact view so + * they round-trip through patch/write tools instead of being silently dropped. + * Must stay in sync with the top-level scalar/config fields of `FlowValue`. + */ +export const flowValueSettingsSchema = z + .object({ + same_worker: z.boolean(), + preserve_step_tags: z.boolean(), + concurrent_limit: z.number(), + concurrency_key: z.string(), + concurrency_time_window_s: z.number(), + debounce_delay_s: z.number(), + debounce_key: z.string(), + debounce_args_to_accumulate: z.array(z.string()), + max_total_debouncing_time: z.number(), + max_total_debounces_amount: z.number(), + skip_expr: z.string(), + cache_ttl: z.number(), + cache_ignore_s3_path: z.boolean(), + delete_after_secs: z.number(), + flow_env: z.record(z.string(), z.any()), + priority: z.number(), + early_return: z.string(), + chat_input_enabled: z.boolean() + }) + .partial() + +type FlowValueSettingsKey = keyof z.infer & keyof FlowValue + +export const FLOW_VALUE_SETTINGS_KEYS = Object.keys( + flowValueSettingsSchema.shape +) as FlowValueSettingsKey[] + +export type FlowValueSettings = Pick + +/** + * Extract the defined non-structural settings from a FlowValue (or an + * EditableFlowJson, which carries the same keys). + */ +export function pickFlowValueSettings(source: Record): FlowValueSettings { + const settings: Record = {} + for (const key of FLOW_VALUE_SETTINGS_KEYS) { + if (source[key] !== undefined) { + settings[key] = source[key] + } + } + return settings as FlowValueSettings +} diff --git a/frontend/src/lib/components/copilot/chat/flow/helperUtils.test.ts b/frontend/src/lib/components/copilot/chat/flow/helperUtils.test.ts index 0d46e36f6a..ae763155f7 100644 --- a/frontend/src/lib/components/copilot/chat/flow/helperUtils.test.ts +++ b/frontend/src/lib/components/copilot/chat/flow/helperUtils.test.ts @@ -53,6 +53,39 @@ function makeAiAgentWithTool(agentId: string, toolId: string, toolContent: strin } describe('applyFlowJsonUpdate', () => { + it('leaves top-level flow settings untouched when settings is not provided', () => { + const flow = { + value: { + modules: [makeRawScriptModule('step_a', 'code a')], + chat_input_enabled: true, + same_worker: true + } + } + + applyFlowJsonUpdate(flow as any, createInlineScriptSession(), { + modules: [makeRawScriptModule('step_b', 'code b')] + }) + + expect((flow.value as any).chat_input_enabled).toBe(true) + expect((flow.value as any).same_worker).toBe(true) + }) + + it('applies full-state settings: sets provided keys and removes absent ones', () => { + const flow = { + value: { + modules: [makeRawScriptModule('step_a', 'code a')], + chat_input_enabled: true + } + } + + applyFlowJsonUpdate(flow as any, createInlineScriptSession(), { + settings: { same_worker: true } + }) + + expect((flow.value as any).same_worker).toBe(true) + expect('chat_input_enabled' in flow.value).toBe(false) + }) + it('accepts new self-referenced inline scripts and initializes them as empty', () => { const flow = { value: { diff --git a/frontend/src/lib/components/copilot/chat/flow/helperUtils.ts b/frontend/src/lib/components/copilot/chat/flow/helperUtils.ts index ba5d98fdfb..c5a7f7be22 100644 --- a/frontend/src/lib/components/copilot/chat/flow/helperUtils.ts +++ b/frontend/src/lib/components/copilot/chat/flow/helperUtils.ts @@ -8,6 +8,7 @@ import { NoteColor } from '$lib/components/graph/noteColors' import type { InlineScriptSession } from './inlineScriptsUtils' +import { FLOW_VALUE_SETTINGS_KEYS, type FlowValueSettings } from './flowValueSettings' /** Allowed note/group color names — matches the NoteColor palette the note and * group editors use. The note renderer keys `NOTE_COLORS` by these exact names, @@ -56,6 +57,9 @@ export interface FlowJsonUpdate { failureModule?: FlowModule | null groups?: FlowGroup[] | null notes?: FlowNote[] | null + /** Full state of the top-level FlowValue settings: when provided, keys + * absent from it are removed from the flow value. */ + settings?: FlowValueSettings } export interface FlowJsonUpdateResult { @@ -269,7 +273,7 @@ export function validateFlowNotes(rawNotes: unknown, moduleIds?: Set): F export function applyFlowJsonUpdate( flow: FlowLike, inlineScriptSession: InlineScriptSession, - { modules, schema, preprocessorModule, failureModule, groups, notes }: FlowJsonUpdate + { modules, schema, preprocessorModule, failureModule, groups, notes, settings }: FlowJsonUpdate ): FlowJsonUpdateResult { const emptyInlineScriptModuleIds = new Set() @@ -307,6 +311,16 @@ export function applyFlowJsonUpdate( flow.value.notes = notes == null || notes.length === 0 ? undefined : notes } + if (settings !== undefined) { + for (const key of FLOW_VALUE_SETTINGS_KEYS) { + if (settings[key] !== undefined) { + ;(flow.value as Record)[key] = settings[key] + } else { + delete (flow.value as Record)[key] + } + } + } + return { emptyInlineScriptModuleIds: Array.from(emptyInlineScriptModuleIds) } diff --git a/frontend/src/lib/components/copilot/chat/global/core.test.ts b/frontend/src/lib/components/copilot/chat/global/core.test.ts index 19c05e00ef..2ce668d789 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.test.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.test.ts @@ -1941,7 +1941,7 @@ describe('global AI tools', () => { path: 'f/flows/existing', summary: 'deployed summary', description: 'deployed description', - value: { modules: [] }, + value: { modules: [], chat_input_enabled: true, same_worker: true }, schema: { properties: { deployed: { type: 'boolean' } } }, edited_by: 'admin', edited_at: '2026-05-22T09:00:00Z', @@ -1961,7 +1961,11 @@ describe('global AI tools', () => { path: 'f/flows/existing', summary: 'new summary', description: 'deployed description', - value: { modules: [{ id: 'step', value: { type: 'identity' } }] } + value: { + modules: [{ id: 'step', value: { type: 'identity' } }], + chat_input_enabled: true, + same_worker: true + } }) }) diff --git a/frontend/src/lib/components/copilot/chat/global/core.ts b/frontend/src/lib/components/copilot/chat/global/core.ts index e512c60d90..80204ba02b 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.ts @@ -58,7 +58,10 @@ import { isChromiumBrowser } from '$lib/utils' import { applyEditableFlowJsonToFlow, buildEditableFlowJson, + EDITABLE_FLOW_STRUCTURAL_KEYS, type EditableFlowJson, + FLOW_VALUE_SETTINGS_KEYS, + pickFlowValueSettings, finalizeUnresolvedInlineScripts, restoreSpecialRawscriptModule, validateEditableFlowJson @@ -521,6 +524,7 @@ function appendEmptyInlineScriptWarning(result: string, editable: EditableFlowJs function editableFlowToDraftValue(editable: EditableFlowJson): FlowDraftValue { const value: FlowValue = { + ...pickFlowValueSettings(editable), modules: editable.modules, preprocessor_module: editable.preprocessor_module ?? undefined, failure_module: editable.failure_module ?? undefined, @@ -1947,8 +1951,9 @@ function getFlowInstructions(): string { - Global mode writes complete draft payloads only; it does not save, deploy, run, scaffold local files, or generate metadata. - Paths follow the conventions in the system prompt: default to \`u//\` when the user gave a bare name; only use \`f//\` when the folder is known to exist. Never invent a folder. -- \`write_flow\` mirrors flow mode's \`set_flow_json\`: pass \`path\`, optional \`summary\`, optional \`description\`, required \`modules\`, and optional \`schema\`, \`preprocessor_module\`, \`failure_module\`, \`groups\`, and \`notes\`. \`summary\` and \`description\` are top-level flow metadata (not part of the compact value \`patch_flow_json\` edits); the flow-structure arguments are JSON strings, matching the tool schema descriptions. -- \`read_workspace_item\` returns a compact flow \`value\` object with \`modules\`, \`schema\`, \`preprocessor_module\`, \`failure_module\`, \`groups\`, and \`notes\`. +- \`write_flow\` mirrors flow mode's \`set_flow_json\`: pass \`path\`, optional \`summary\`, optional \`description\`, required \`modules\`, and optional \`schema\`, \`preprocessor_module\`, \`failure_module\`, \`groups\`, and \`notes\`. \`summary\` and \`description\` are top-level flow metadata (not part of the compact value \`patch_flow_json\` edits); the flow-structure arguments are JSON strings, matching the tool schema descriptions. When overwriting an existing flow, top-level flow settings (see below) are preserved from the current flow — use \`patch_flow_json\` to change them. +- \`read_workspace_item\` returns a compact flow \`value\` object with \`modules\`, \`schema\`, \`preprocessor_module\`, \`failure_module\`, \`groups\`, \`notes\`, and any top-level flow settings that are set. +- Top-level flow settings appear as top-level keys of the compact flow value and can be added, edited, or removed with \`patch_flow_json\`: ${FLOW_VALUE_SETTINGS_KEYS.join(', ')}. For example, \`chat_input_enabled: true\` marks a flow as chat-style (flow-as-chat); keep it intact when restructuring such a flow. - \`modules\` contains normal sequential modules. Use top-level \`preprocessor_module\` and \`failure_module\` for special modules; do not put \`preprocessor\` or \`failure\` in \`modules\`. - Every module needs a stable unique \`id\` and a useful \`summary\` when the schema supports it. - Prefer path/script/flow modules when composing existing workspace logic. Use rawscript modules only when new inline code is needed. @@ -2878,7 +2883,8 @@ export const globalTools: Tool<{}>[] = [ path: parsed.path, summary: parsed.summary, description: parsed.description, - flow: editableFlowToDraftValue(resolved) + flow: editableFlowToDraftValue(resolved), + preserveBaseValueSettings: true }, ctx ) @@ -4169,8 +4175,14 @@ type FlowDraftArgs = { description?: string flow: FlowDraftValue override?: boolean + /** Carry over the base value's non-structural fields (chat_input_enabled, + * same_worker, ...) into the new value. Set by write_flow, whose arguments + * cannot express them; patch_flow_json passes the full value state instead. */ + preserveBaseValueSettings?: boolean } +const FLOW_STRUCTURAL_VALUE_KEYS = new Set(EDITABLE_FLOW_STRUCTURAL_KEYS) + const FLOW_SPEC: WriteSpec = { probe: (workspace, path) => FlowService.existsFlowByPath({ workspace, path }), fetchDeployed: (workspace, path) => FlowService.getFlowByPath({ workspace, path }), @@ -4179,6 +4191,16 @@ const FLOW_SPEC: WriteSpec = { if (args.flow.groups !== undefined && args.flow.groups !== null) { value.groups = structuredClone(args.flow.groups) } + if (args.preserveBaseValueSettings && base?.value) { + for (const [key, fieldValue] of Object.entries(base.value)) { + if ( + !FLOW_STRUCTURAL_VALUE_KEYS.has(key) && + (value as Record)[key] === undefined + ) { + ;(value as Record)[key] = structuredClone(fieldValue) + } + } + } return base ? { ...structuredClone(base), diff --git a/frontend/src/lib/components/flows/content/FlowInput.svelte b/frontend/src/lib/components/flows/content/FlowInput.svelte index 03125168e0..21bbdaa1ec 100644 --- a/frontend/src/lib/components/flows/content/FlowInput.svelte +++ b/frontend/src/lib/components/flows/content/FlowInput.svelte @@ -44,7 +44,7 @@ import CaptureTable from '$lib/components/triggers/CaptureTable.svelte' import { isObjectTooBig, readFieldsRecursively } from '$lib/utils' import { refreshFlowStateStore } from '$lib/components/flows/flowStoreRefresh.svelte' - import type { AiAgent, ScriptLang } from '$lib/gen' + import type { AiAgent, InputTransform, ScriptLang } from '$lib/gen' import { deepEqual } from 'fast-equals' import Toggle from '$lib/components/Toggle.svelte' import { AI_AGENT_SCHEMA } from '../flowInfers' @@ -599,24 +599,45 @@ false ) } else if (aiAgentModules.length === 1) { - // Exactly one AI agent exists, configure it + // Exactly one AI agent exists: fill in defaults only for inputs the + // user hasn't configured, so re-enabling chat mode on an already + // configured agent doesn't clobber a custom user_message expression + // or a deliberate memory choice (e.g. off). const aiAgent = aiAgentModules[0] const value = aiAgent.value as AiAgent - // Set user_message to flow_input.user_message - value.input_transforms['user_message'] = { - type: 'javascript', - expr: 'flow_input.user_message' + // Degenerate shapes the input form can produce without deliberate + // configuration count as unconfigured: empty static value (undefined + // persists as null through JSON round-trips), blank JS expression + // (the JS toggle seeds a bare backtick pair), or an AI transform + // (meaningless for the chat input). + const isUnconfigured = (transform: InputTransform | undefined) => + transform === undefined || + (transform.type === 'static' && (transform.value == null || transform.value === '')) || + (transform.type === 'javascript' && transform.expr.replaceAll('`', '').trim() === '') || + transform.type === 'ai' + + const applied: string[] = [] + if (isUnconfigured(value.input_transforms['user_message'])) { + value.input_transforms['user_message'] = { + type: 'javascript', + expr: 'flow_input.user_message' + } + applied.push('user message input') } - // Set messages_context_length to 10 - value.input_transforms['memory'] = { - type: 'static', - value: { kind: 'auto', context_length: 10 } + if (isUnconfigured(value.input_transforms['memory'])) { + value.input_transforms['memory'] = { + type: 'static', + value: { kind: 'auto', context_length: 10 } + } + applied.push('context memory set to 10') } sendUserToast( - 'Chat mode enabled. AI agent configured with user message input and context memory set to 10.', + applied.length > 0 + ? `Chat mode enabled. AI agent configured with ${applied.join(' and ')}.` + : 'Chat mode enabled. Existing AI agent configuration kept unchanged.', false ) } diff --git a/frontend/src/lib/components/raw_apps/RawAppPreview.svelte b/frontend/src/lib/components/raw_apps/RawAppPreview.svelte index 765e06cd02..6adc86336b 100644 --- a/frontend/src/lib/components/raw_apps/RawAppPreview.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppPreview.svelte @@ -90,8 +90,13 @@ // `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' : '' + // backend adds the header when the flag is present. Also request it when + // this document is itself cross-origin isolated (e.g. the raw app editor) + // — the wrapper would otherwise be blocked outright, URL flag or not. + const coep = + new URLSearchParams(window.location.search).has('wm_coep') || window.crossOriginIsolated + ? '?wm_coep=1' + : '' return `/api/w/${workspace}/apps_u/get_data/v/${secret}.html${coep}` }) diff --git a/frontend/src/routes/(root)/(logged)/+layout.svelte b/frontend/src/routes/(root)/(logged)/+layout.svelte index 323f6b9ed7..c2092f5059 100644 --- a/frontend/src/routes/(root)/(logged)/+layout.svelte +++ b/frontend/src/routes/(root)/(logged)/+layout.svelte @@ -415,8 +415,10 @@ // This ensures the cross-origin isolation headers are fetched from the server // which are required for SharedArrayBuffer and TypeScript workers to work correctly const toPath = navigation.to?.url.pathname - if (toPath && (toPath.startsWith('/apps_raw/add') || toPath.startsWith('/apps_raw/edit'))) { - const currentPath = navigation.from?.url.pathname + const currentPath = navigation.from?.url.pathname + const isEditorPath = (p: string | undefined) => + !!p && (p.startsWith('/apps_raw/add') || p.startsWith('/apps_raw/edit')) + if (isEditorPath(toPath)) { // 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. @@ -424,6 +426,15 @@ navigation.cancel() window.location.href = navigation.to!.url.href } + } else if (toPath && (isEditorPath(currentPath) || window.crossOriginIsolated)) { + // Reverse of the guard above: leaving the isolated editor document must + // also fully reload, or its COEP header sticks for the rest of the SPA + // session and blocks CORP-less cross-origin subresources (e.g. images in + // a viewed app — see needs_cross_origin_isolation in static_assets.rs). + // The path check covers plain-HTTP origins, where `crossOriginIsolated` + // stays false even with the headers applied. + navigation.cancel() + window.location.href = navigation.to!.url.href } }) diff --git a/frontend/vite.config.js b/frontend/vite.config.js index 2f42a1889d..aa2e8870d9 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -24,15 +24,33 @@ const remoteUrl = const cookieDomain = process.env.ISOLATE_DEV_AUTH === '1' ? '' : 'localhost' +// Cross-origin isolation headers, scoped to mirror the production predicate — +// see `needs_cross_origin_isolation` in backend/windmill-api/src/static_assets.rs +// for which paths need them and why the raw app viewer must be excluded. // `enforce: 'pre'` so these headers are set before SvelteKit's sirv static // handler serves `static/` files and ends the response without calling next(). +function needsCrossOriginIsolation(url) { + const [path, query = ''] = url.split('?') + return ( + path.startsWith('/apps_raw/edit') || + path.startsWith('/apps_raw/add') || + path.startsWith('/ui_builder/') || + ((path.startsWith('/public/') || path.startsWith('/a/')) && + new URLSearchParams(query).has('wm_coep')) + ) +} + let plugin = { name: 'configure-response-headers', enforce: 'pre', configureServer: (server) => { - server.middlewares.use((_req, res, next) => { - res.setHeader('Cross-Origin-Opener-Policy', 'same-origin') - res.setHeader('Cross-Origin-Embedder-Policy', 'require-corp') + server.middlewares.use((req, res, next) => { + if (needsCrossOriginIsolation(req.url ?? '')) { + res.setHeader('Cross-Origin-Opener-Policy', 'same-origin') + res.setHeader('Cross-Origin-Embedder-Policy', 'require-corp') + } + // CORP on everything so dev assets stay loadable as subresources of + // isolated documents on other dev origins (e.g. 127.0.0.1 vs localhost). res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin') next() })