From 22f2e34fe6acb1b68311aaa0172f12c989375446 Mon Sep 17 00:00:00 2001 From: Albert Alises Date: Tue, 12 May 2026 15:53:18 +0200 Subject: [PATCH] fix(core): Stop workflow builder after terminal remediation (#30289) --- .../builder-sandbox-session-registry.ts | 12 +- .../build-workflow-agent.tool.test.ts | 46 +++++ .../build-workflow-agent.prompt.ts | 1 + .../build-workflow-agent.tool.ts | 118 ++++++++++- .../submit-workflow-identity.test.ts | 76 +++++++ .../workflows/submit-workflow-identity.ts | 28 ++- .../instance-ai/src/workflow-loop/index.ts | 4 + .../terminal-remediation-guard.ts | 25 +++ .../__tests__/guarded-filesystem.test.ts | 90 +++++++++ .../src/workspace/builder-sandbox-factory.ts | 14 +- .../src/workspace/guarded-filesystem.ts | 186 ++++++++++++++++++ 11 files changed, 588 insertions(+), 12 deletions(-) create mode 100644 packages/@n8n/instance-ai/src/workflow-loop/terminal-remediation-guard.ts create mode 100644 packages/@n8n/instance-ai/src/workspace/__tests__/guarded-filesystem.test.ts create mode 100644 packages/@n8n/instance-ai/src/workspace/guarded-filesystem.ts diff --git a/packages/@n8n/instance-ai/src/runtime/builder-sandbox-session-registry.ts b/packages/@n8n/instance-ai/src/runtime/builder-sandbox-session-registry.ts index 1592e6c58a3..6b3fe9f0fdd 100644 --- a/packages/@n8n/instance-ai/src/runtime/builder-sandbox-session-registry.ts +++ b/packages/@n8n/instance-ai/src/runtime/builder-sandbox-session-registry.ts @@ -2,6 +2,7 @@ import type { Workspace } from '@mastra/core/workspace'; import { nanoid } from 'nanoid'; import type { BuilderWorkspace } from '../workspace/builder-sandbox-factory'; +import type { FilesystemMutationGuardSetter } from '../workspace/guarded-filesystem'; interface BuilderSandboxSessionInternal { sessionId: string; @@ -13,6 +14,7 @@ interface BuilderSandboxSessionInternal { workspace: Workspace; root: string; cleanup: () => Promise; + setFilesystemMutationGuard?: FilesystemMutationGuardSetter; busy: boolean; createdAt: number; updatedAt: number; @@ -29,6 +31,7 @@ export interface BuilderSandboxSession { builderResourceId: string; workspace: Workspace; root: string; + setFilesystemMutationGuard?: FilesystemMutationGuardSetter; busy: boolean; createdAt: number; updatedAt: number; @@ -50,7 +53,7 @@ function sessionKey(threadId: string, value: string): string { } function toPublicSession(session: BuilderSandboxSessionInternal): BuilderSandboxSession { - return { + const publicSession: BuilderSandboxSession = { sessionId: session.sessionId, threadId: session.threadId, workflowId: session.workflowId, @@ -64,6 +67,10 @@ function toPublicSession(session: BuilderSandboxSessionInternal): BuilderSandbox updatedAt: session.updatedAt, expiresAt: session.expiresAt, }; + if (session.setFilesystemMutationGuard) { + publicSession.setFilesystemMutationGuard = session.setFilesystemMutationGuard; + } + return publicSession; } export class BuilderSandboxSessionRegistry { @@ -109,6 +116,9 @@ export class BuilderSandboxSessionRegistry { updatedAt: now, expiresAt: now + this.ttlMs, }; + if (input.builderWorkspace.setFilesystemMutationGuard) { + session.setFilesystemMutationGuard = input.builderWorkspace.setFilesystemMutationGuard; + } this.sessions.set(session.sessionId, session); if (session.workflowId) { diff --git a/packages/@n8n/instance-ai/src/tools/orchestration/__tests__/build-workflow-agent.tool.test.ts b/packages/@n8n/instance-ai/src/tools/orchestration/__tests__/build-workflow-agent.tool.test.ts index 096129bb41c..d4e09bcc45a 100644 --- a/packages/@n8n/instance-ai/src/tools/orchestration/__tests__/build-workflow-agent.tool.test.ts +++ b/packages/@n8n/instance-ai/src/tools/orchestration/__tests__/build-workflow-agent.tool.test.ts @@ -26,6 +26,7 @@ import type { const { recordSuccessfulWorkflowBuilds, resultFromPostStreamError, + resultFromTerminalRemediation, resultFromLaterFailedMainSubmit, attemptFromAutoResubmit, withTerminalLoopState, @@ -563,6 +564,51 @@ describe('resultFromPostStreamError', () => { }); }); +describe('resultFromTerminalRemediation', () => { + it('returns terminal remediation without requiring a final auto-resubmit', () => { + const remediation = createRemediation({ + category: 'blocked', + shouldEdit: false, + reason: 'workflow_save_failed', + guidance: 'Stop editing.', + }); + const submitAttempts: SubmitWorkflowAttempt[] = [ + { + filePath: MAIN_PATH, + sourceHash: 'a', + success: true, + workflowId: 'WF_123', + }, + { + filePath: MAIN_PATH, + sourceHash: 'b', + success: false, + errors: ['Workflow save failed.'], + remediation, + }, + ]; + + const result = resultFromTerminalRemediation({ + remediation, + submitAttempts, + mainWorkflowPath: MAIN_PATH, + workItemId: 'wi_test', + runId: 'run_test', + taskId: 'task_test', + }); + + expect(result).toMatchObject({ + text: 'Stop editing.', + outcome: { + submitted: true, + workflowId: 'WF_123', + blockingReason: 'Stop editing.', + remediation, + }, + }); + }); +}); + describe('supportingWorkflowIdsFromSubmitAttempts', () => { it('collects referenced successful non-main workflow IDs once in submit order', () => { const submitAttempts: SubmitWorkflowAttempt[] = [ diff --git a/packages/@n8n/instance-ai/src/tools/orchestration/build-workflow-agent.prompt.ts b/packages/@n8n/instance-ai/src/tools/orchestration/build-workflow-agent.prompt.ts index 4f987b0c18d..4fb104e02af 100644 --- a/packages/@n8n/instance-ai/src/tools/orchestration/build-workflow-agent.prompt.ts +++ b/packages/@n8n/instance-ai/src/tools/orchestration/build-workflow-agent.prompt.ts @@ -418,6 +418,7 @@ n8n normalizes column names to snake_case (e.g., \`dayName\` → \`day_name\`). 8. **Submit**: When tsc passes cleanly, call \`submit-workflow\` to validate the workflow graph and save it to n8n. 9. **Fix submission errors**: If \`submit-workflow\` returns errors, edit the file and submit again immediately. Skip tsc for validation-only errors. **Never end your turn on a file edit — always re-submit first.** The system compares file hashes: if the file changed since the last submit, all your work is discarded. End only on a successful re-submit or after you explicitly report the blocking error. + If remediation includes \`shouldEdit: false\`, stop immediately and report its guidance. Do not edit files, run commands, or call \`submit-workflow\` again. 10. **Done**: Output ONE sentence summarizing what was built, including the workflow ID and any known issues. diff --git a/packages/@n8n/instance-ai/src/tools/orchestration/build-workflow-agent.tool.ts b/packages/@n8n/instance-ai/src/tools/orchestration/build-workflow-agent.tool.ts index a2a63585cf6..52047955e7c 100644 --- a/packages/@n8n/instance-ai/src/tools/orchestration/build-workflow-agent.tool.ts +++ b/packages/@n8n/instance-ai/src/tools/orchestration/build-workflow-agent.tool.ts @@ -46,6 +46,8 @@ import type { BackgroundTaskResult, InstanceAiContext, OrchestrationContext } fr import { SDK_IMPORT_STATEMENT } from '../../workflow-builder/extract-code'; import { createRemediation, + createTerminalRemediationGuard, + type RemediationMetadata, type TriggerType, type WorkflowBuildOutcome, type WorkflowSetupRequirement, @@ -556,6 +558,19 @@ function hashContent(content: string | null): string { .digest('hex'); } +function createLinkedAbortController(parentSignal: AbortSignal): AbortController { + const controller = new AbortController(); + if (parentSignal.aborted) { + controller.abort(parentSignal.reason); + return controller; + } + + parentSignal.addEventListener('abort', () => controller.abort(parentSignal.reason), { + once: true, + }); + return controller; +} + function deterministicSuffix(seed: string, label: string, length: number): string { return createHash('sha256') .update(label) @@ -683,6 +698,47 @@ export function resultFromPostStreamError(input: { }; } +export function resultFromTerminalRemediation(input: { + remediation: RemediationMetadata; + submitAttempts: SubmitWorkflowAttempt[]; + mainWorkflowPath: string; + workItemId: string; + runId: string; + taskId: string; +}): { text: string; outcome: WorkflowBuildOutcome } { + const latestAttempt = latestMainSubmit(input.submitAttempts, input.mainWorkflowPath); + const attempt = + latestAttempt && + !latestAttempt.success && + shouldRecoverSavedWorkflowAfterFailedSubmit(latestAttempt) + ? (latestSuccessfulMainSubmit(input.submitAttempts, input.mainWorkflowPath) ?? latestAttempt) + : latestAttempt; + const text = input.remediation.guidance; + const outcome = buildOutcome( + input.workItemId, + input.runId, + input.taskId, + attempt, + text, + supportingWorkflowIdsFromSubmitAttempts( + input.submitAttempts, + input.mainWorkflowPath, + attempt?.workflowId, + attempt?.referencedWorkflowIds, + ), + ); + + return { + text, + outcome: withDeterministicRouting({ + ...outcome, + needsUserInput: outcome.needsUserInput || input.remediation.category === 'needs_setup', + blockingReason: input.remediation.guidance, + remediation: input.remediation, + }), + }; +} + async function getWorkflowNodeSummaries( context: InstanceAiContext | undefined, workflowId: string | undefined, @@ -1016,6 +1072,19 @@ export async function startBuildWorkflowAgentTask( // Append-only history so a later failed submit for the main path // cannot mask an earlier successful submit during post-error recovery. const submitAttemptHistory: SubmitWorkflowAttempt[] = []; + const builderAbortController = createLinkedAbortController(signal); + const terminalRemediationGuard = createTerminalRemediationGuard((remediation) => { + context.trackTelemetry?.('Builder terminal remediation reached', { + thread_id: context.threadId, + run_id: context.runId, + work_item_id: workItemId, + category: remediation.category, + attempt_count: remediation.attemptCount, + reason: remediation.reason, + }); + builderAbortController.abort(new Error(remediation.guidance)); + }); + let clearFilesystemMutationGuard: (() => void) | undefined; try { if (useSandbox) { let workspace: BuilderWorkspace['workspace']; @@ -1058,6 +1127,31 @@ export async function startBuildWorkflowAgentTask( } const mainWorkflowPath = `${root}/src/workflow.ts`; + const setFilesystemMutationGuard = + activeBuilderSession?.setFilesystemMutationGuard ?? + builderWs?.setFilesystemMutationGuard; + if (setFilesystemMutationGuard) { + setFilesystemMutationGuard(() => terminalRemediationGuard.get()); + clearFilesystemMutationGuard = () => setFilesystemMutationGuard(undefined); + } + const finishTerminalRemediation = async (remediation: RemediationMetadata) => { + const terminalResult = resultFromTerminalRemediation({ + remediation, + submitAttempts: submitAttemptHistory, + mainWorkflowPath, + workItemId, + runId: context.runId, + taskId, + }); + if (terminalResult.outcome.submitted && terminalResult.outcome.workflowId) { + await promoteMainWorkflow( + domainContext, + context.logger, + terminalResult.outcome.workflowId, + ); + } + return await finalizeBuildResult(context, workItemId, terminalResult); + }; builderTools['submit-workflow'] = createIdentityEnforcedSubmitWorkflowTool({ context: domainContext, workspace, @@ -1067,6 +1161,7 @@ export async function startBuildWorkflowAgentTask( tracingRoot: traceContext?.rootRun, getWorkflowLoopState: async () => await context.workflowTaskService?.getWorkflowLoopState(workItemId), + getTerminalRemediation: () => terminalRemediationGuard.get(), onGuardFired: (event) => { context.trackTelemetry?.('Builder remediation guard fired', { thread_id: context.threadId, @@ -1078,6 +1173,9 @@ export async function startBuildWorkflowAgentTask( reason: event.reason, }); }, + onTerminalRemediation: (remediation) => { + terminalRemediationGuard.record(remediation); + }, onAttempt: async (attempt) => { submitAttempts.set(attempt.filePath, attempt); submitAttemptHistory.push(attempt); @@ -1165,7 +1263,7 @@ export async function startBuildWorkflowAgentTask( }; const stream = await subAgent.stream(briefing, { maxSteps: MAX_STEPS.BUILDER, - abortSignal: signal, + abortSignal: builderAbortController.signal, modelSettings: { temperature: TEMPERATURE.BUILDER }, providerOptions: { anthropic: { cacheControl: { type: 'ephemeral' } }, @@ -1188,7 +1286,7 @@ export async function startBuildWorkflowAgentTask( eventBus: context.eventBus, logger: context.logger, threadId: context.threadId, - abortSignal: signal, + abortSignal: builderAbortController.signal, waitForConfirmation: context.waitForConfirmation, drainCorrections, waitForCorrection, @@ -1199,8 +1297,18 @@ export async function startBuildWorkflowAgentTask( }); }); + const terminalRemediation = terminalRemediationGuard.get(); + if (terminalRemediation) { + return await finishTerminalRemediation(terminalRemediation); + } + finalText = await hitlResult.text; } catch (error) { + const terminalRemediation = terminalRemediationGuard.get(); + if (terminalRemediation) { + return await finishTerminalRemediation(terminalRemediation); + } + const recovered = resultFromPostStreamError({ error, submitAttempts: submitAttemptHistory, @@ -1220,6 +1328,11 @@ export async function startBuildWorkflowAgentTask( throw error; } + const terminalRemediation = terminalRemediationGuard.get(); + if (terminalRemediation) { + return await finishTerminalRemediation(terminalRemediation); + } + const mainWorkflowAttempt = submitAttempts.get(mainWorkflowPath); const currentMainWorkflow = await readFileViaSandbox(workspace, mainWorkflowPath); const currentMainWorkflowHash = hashContent(currentMainWorkflow); @@ -1481,6 +1594,7 @@ export async function startBuildWorkflowAgentTask( await promoteMainWorkflow(domainContext, context.logger, fallbackMainWorkflowId); return { text: toolFinalText }; } finally { + clearFilesystemMutationGuard?.(); if (activeBuilderSession && context.builderSandboxSessionRegistry) { await context.builderSandboxSessionRegistry.release(activeBuilderSession.sessionId, { keep: !signal.aborted, diff --git a/packages/@n8n/instance-ai/src/tools/workflows/__tests__/submit-workflow-identity.test.ts b/packages/@n8n/instance-ai/src/tools/workflows/__tests__/submit-workflow-identity.test.ts index 646a95e4065..48a0841990d 100644 --- a/packages/@n8n/instance-ai/src/tools/workflows/__tests__/submit-workflow-identity.test.ts +++ b/packages/@n8n/instance-ai/src/tools/workflows/__tests__/submit-workflow-identity.test.ts @@ -232,6 +232,82 @@ describe('wrapSubmitExecuteWithIdentity', () => { ); }); + it('records terminal submit output and blocks later submits in-process', async () => { + let terminalRemediation: SubmitWorkflowOutput['remediation']; + const remediation = createRemediation({ + category: 'blocked', + shouldEdit: false, + reason: 'workflow_save_failed', + guidance: 'Stop editing.', + }); + const execute = jest + .fn, [SubmitWorkflowInput]>() + .mockResolvedValueOnce({ + success: false, + errors: ['Workflow save failed.'], + remediation, + }) + .mockResolvedValueOnce({ success: true, workflowId: 'wf_should_not_save' }); + const wrapped = wrapSubmitExecuteWithIdentity(execute, resolvePath, { + getTerminalRemediation: () => terminalRemediation, + onTerminalRemediation: (recorded) => { + terminalRemediation = recorded; + }, + }); + + const first = await wrapped({}); + const second = await wrapped({}); + + expect(first.remediation).toBe(remediation); + expect(second).toMatchObject({ + success: false, + errors: ['Stop editing.'], + remediation, + }); + expect(execute).toHaveBeenCalledTimes(1); + }); + + it('returns terminal remediation to concurrent submit waiters when the first submit stops editing', async () => { + let release: () => void = () => {}; + const gate = new Promise((res) => { + release = res; + }); + let terminalRemediation: SubmitWorkflowOutput['remediation']; + const remediation = createRemediation({ + category: 'blocked', + shouldEdit: false, + reason: 'workflow_save_failed', + guidance: 'Stop editing.', + }); + const execute = jest.fn(async (): Promise => { + await gate; + return { + success: false, + errors: ['Workflow save failed.'], + remediation, + }; + }); + const wrapped = wrapSubmitExecuteWithIdentity(execute, resolvePath, { + getTerminalRemediation: () => terminalRemediation, + onTerminalRemediation: (recorded) => { + terminalRemediation = recorded; + }, + }); + + const first = wrapped({}); + const second = wrapped({}); + await Promise.resolve(); + release(); + + await expect(first).resolves.toMatchObject({ success: false, remediation }); + await expect(second).resolves.toMatchObject({ + success: false, + errors: ['Stop editing.'], + remediation, + }); + expect(execute).toHaveBeenCalledTimes(1); + }); + it('ignores terminal remediation from a previous run', async () => { const execute = jest.fn(async (): Promise => { await Promise.resolve(); diff --git a/packages/@n8n/instance-ai/src/tools/workflows/submit-workflow-identity.ts b/packages/@n8n/instance-ai/src/tools/workflows/submit-workflow-identity.ts index 6f73d8fd060..886deb89a88 100644 --- a/packages/@n8n/instance-ai/src/tools/workflows/submit-workflow-identity.ts +++ b/packages/@n8n/instance-ai/src/tools/workflows/submit-workflow-identity.ts @@ -42,6 +42,7 @@ export type SubmitExecute = (input: SubmitWorkflowInput) => Promise Promise; + getTerminalRemediation?: () => RemediationMetadata | undefined; currentRunId?: string; onGuardFired?: (event: { workflowId?: string; @@ -49,6 +50,7 @@ interface SubmitGuardOptions { attemptCount?: number; reason?: string; }) => void; + onTerminalRemediation?: (remediation: RemediationMetadata) => void; } interface SubmitBudgetTracker { @@ -128,10 +130,9 @@ export function wrapSubmitExecuteWithIdentity( async function blockedByTerminalRemediation( workflowId: string | undefined, ): Promise { - const terminalRemediation = terminalRemediationFromState( - await options.getWorkflowLoopState?.(), - options.currentRunId, - ); + const terminalRemediation = + options.getTerminalRemediation?.() ?? + terminalRemediationFromState(await options.getWorkflowLoopState?.(), options.currentRunId); if (!terminalRemediation) return undefined; options.onGuardFired?.({ @@ -147,6 +148,14 @@ export function wrapSubmitExecuteWithIdentity( }; } + function applyOutputGuards(path: string, output: SubmitWorkflowOutput): SubmitWorkflowOutput { + const guardedOutput = options.budgetTracker?.applyToOutput(path, output) ?? output; + if (guardedOutput.remediation?.shouldEdit === false) { + options.onTerminalRemediation?.(guardedOutput.remediation); + } + return guardedOutput; + } + return async (input) => { const resolvedPath = resolvePath(input.filePath); const terminalResult = await blockedByTerminalRemediation(input.workflowId); @@ -159,6 +168,9 @@ export function wrapSubmitExecuteWithIdentity( try { boundId = await existing; } catch (error) { + const terminalAfterFailure = await blockedByTerminalRemediation(input.workflowId); + if (terminalAfterFailure) return terminalAfterFailure; + const message = error instanceof Error ? error.message : String(error); return { success: false, @@ -176,7 +188,7 @@ export function wrapSubmitExecuteWithIdentity( if (terminalAfterWait) return terminalAfterWait; const result = await underlying({ ...input, workflowId: boundId }); - return options.budgetTracker?.applyToOutput(resolvedPath, result) ?? result; + return applyOutputGuards(resolvedPath, result); } let resolveFn: ((id: string) => void) | undefined; @@ -198,7 +210,7 @@ export function wrapSubmitExecuteWithIdentity( rejectFn?.(new Error(result.errors?.join(' ') ?? 'submit-workflow failed')); pending.delete(resolvedPath); } - return options.budgetTracker?.applyToOutput(resolvedPath, result) ?? result; + return applyOutputGuards(resolvedPath, result); } catch (error) { rejectFn?.(error); pending.delete(resolvedPath); @@ -219,7 +231,9 @@ export function createIdentityEnforcedSubmitWorkflowTool(args: { root: string; currentRunId?: string; getWorkflowLoopState?: () => Promise; + getTerminalRemediation?: SubmitGuardOptions['getTerminalRemediation']; onGuardFired?: SubmitGuardOptions['onGuardFired']; + onTerminalRemediation?: SubmitGuardOptions['onTerminalRemediation']; tracingRoot?: InstanceAiTraceRun; }) { const budgetTracker = createPreSaveBudgetTracker(); @@ -245,7 +259,9 @@ export function createIdentityEnforcedSubmitWorkflowTool(args: { budgetTracker, currentRunId: args.currentRunId, getWorkflowLoopState: args.getWorkflowLoopState, + getTerminalRemediation: args.getTerminalRemediation, onGuardFired: args.onGuardFired, + onTerminalRemediation: args.onTerminalRemediation, }, ); diff --git a/packages/@n8n/instance-ai/src/workflow-loop/index.ts b/packages/@n8n/instance-ai/src/workflow-loop/index.ts index 9ef6305aa7b..2da88cbc88f 100644 --- a/packages/@n8n/instance-ai/src/workflow-loop/index.ts +++ b/packages/@n8n/instance-ai/src/workflow-loop/index.ts @@ -52,3 +52,7 @@ export { remainingPostSubmitRemediations, terminalRemediationFromState, } from './remediation'; +export { + createTerminalRemediationGuard, + type TerminalRemediationGuard, +} from './terminal-remediation-guard'; diff --git a/packages/@n8n/instance-ai/src/workflow-loop/terminal-remediation-guard.ts b/packages/@n8n/instance-ai/src/workflow-loop/terminal-remediation-guard.ts new file mode 100644 index 00000000000..58793bd452f --- /dev/null +++ b/packages/@n8n/instance-ai/src/workflow-loop/terminal-remediation-guard.ts @@ -0,0 +1,25 @@ +import type { RemediationMetadata } from './workflow-loop-state'; + +export interface TerminalRemediationGuard { + get(): RemediationMetadata | undefined; + record(remediation: RemediationMetadata | undefined): RemediationMetadata | undefined; +} + +export function createTerminalRemediationGuard( + onTerminal?: (remediation: RemediationMetadata) => void, +): TerminalRemediationGuard { + let terminalRemediation: RemediationMetadata | undefined; + + return { + get: () => terminalRemediation, + record: (remediation) => { + if (!remediation || remediation.shouldEdit || terminalRemediation) { + return terminalRemediation; + } + + terminalRemediation = remediation; + onTerminal?.(remediation); + return terminalRemediation; + }, + }; +} diff --git a/packages/@n8n/instance-ai/src/workspace/__tests__/guarded-filesystem.test.ts b/packages/@n8n/instance-ai/src/workspace/__tests__/guarded-filesystem.test.ts new file mode 100644 index 00000000000..a1d2df04f2e --- /dev/null +++ b/packages/@n8n/instance-ai/src/workspace/__tests__/guarded-filesystem.test.ts @@ -0,0 +1,90 @@ +import type { WorkspaceFilesystem } from '@mastra/core/workspace'; + +import { createGuardedFilesystem } from '../guarded-filesystem'; + +function createFilesystemMock(): jest.Mocked { + return { + id: 'fs-1', + name: 'MockFilesystem', + provider: 'mock', + status: 'ready', + readFile: jest.fn().mockResolvedValue('content'), + writeFile: jest.fn().mockResolvedValue(undefined), + appendFile: jest.fn().mockResolvedValue(undefined), + deleteFile: jest.fn().mockResolvedValue(undefined), + copyFile: jest.fn().mockResolvedValue(undefined), + moveFile: jest.fn().mockResolvedValue(undefined), + mkdir: jest.fn().mockResolvedValue(undefined), + rmdir: jest.fn().mockResolvedValue(undefined), + readdir: jest.fn().mockResolvedValue([]), + exists: jest.fn().mockResolvedValue(true), + stat: jest.fn().mockResolvedValue({ + name: 'workflow.ts', + path: '/workspace/src/workflow.ts', + type: 'file', + size: 7, + createdAt: new Date(0), + modifiedAt: new Date(0), + }), + }; +} + +describe('createGuardedFilesystem', () => { + it('allows reads and writes while no terminal remediation is set', async () => { + const rawFilesystem = createFilesystemMock(); + const { filesystem } = createGuardedFilesystem(rawFilesystem); + + await expect(filesystem.readFile('/workspace/src/workflow.ts')).resolves.toBe('content'); + await expect( + filesystem.writeFile('/workspace/src/workflow.ts', 'updated'), + ).resolves.toBeUndefined(); + + expect(rawFilesystem.readFile).toHaveBeenCalledTimes(1); + expect(rawFilesystem.writeFile).toHaveBeenCalledWith( + '/workspace/src/workflow.ts', + 'updated', + undefined, + ); + }); + + it('blocks mutating operations after terminal remediation', async () => { + const rawFilesystem = createFilesystemMock(); + const { filesystem, setMutationGuard } = createGuardedFilesystem(rawFilesystem); + setMutationGuard(() => ({ guidance: 'Stop editing.' })); + + await expect(filesystem.readFile('/workspace/src/workflow.ts')).resolves.toBe('content'); + await expect(filesystem.writeFile('/workspace/src/workflow.ts', 'updated')).rejects.toThrow( + 'Stop editing.', + ); + await expect(filesystem.mkdir('/workspace/chunks')).rejects.toThrow('Stop editing.'); + await expect(filesystem.deleteFile('/workspace/src/workflow.ts')).rejects.toThrow( + 'Stop editing.', + ); + + expect(rawFilesystem.readFile).toHaveBeenCalledTimes(1); + expect(rawFilesystem.writeFile).not.toHaveBeenCalled(); + expect(rawFilesystem.mkdir).not.toHaveBeenCalled(); + expect(rawFilesystem.deleteFile).not.toHaveBeenCalled(); + }); + + it('can clear the mutation guard for a reused workspace', async () => { + const rawFilesystem = createFilesystemMock(); + const { filesystem, setMutationGuard } = createGuardedFilesystem(rawFilesystem); + + setMutationGuard(() => ({ guidance: 'Stop editing.' })); + await expect(filesystem.writeFile('/workspace/src/workflow.ts', 'blocked')).rejects.toThrow( + 'Stop editing.', + ); + + setMutationGuard(undefined); + await expect( + filesystem.writeFile('/workspace/src/workflow.ts', 'allowed'), + ).resolves.toBeUndefined(); + + expect(rawFilesystem.writeFile).toHaveBeenCalledWith( + '/workspace/src/workflow.ts', + 'allowed', + undefined, + ); + }); +}); diff --git a/packages/@n8n/instance-ai/src/workspace/builder-sandbox-factory.ts b/packages/@n8n/instance-ai/src/workspace/builder-sandbox-factory.ts index d199823b6e0..87c3a5da272 100644 --- a/packages/@n8n/instance-ai/src/workspace/builder-sandbox-factory.ts +++ b/packages/@n8n/instance-ai/src/workspace/builder-sandbox-factory.ts @@ -16,6 +16,7 @@ import { join as posixJoin } from 'node:path/posix'; import type { ErrorReporter, Logger } from '../logger'; import type { SandboxConfig } from './create-workspace'; import { DaytonaFilesystem } from './daytona-filesystem'; +import { createGuardedFilesystem, type FilesystemMutationGuardSetter } from './guarded-filesystem'; import { N8nSandboxFilesystem } from './n8n-sandbox-filesystem'; import { N8nSandboxServiceSandbox } from './n8n-sandbox-sandbox'; import { @@ -38,6 +39,7 @@ const NOOP_LOGGER: Logger = { export interface BuilderWorkspace { workspace: Workspace; cleanup: () => Promise; + setFilesystemMutationGuard?: FilesystemMutationGuardSetter; } async function cleanupTrackedSandboxProcesses(workspace: Workspace): Promise { @@ -248,9 +250,10 @@ export class BuilderSandboxFactory { timeout: config.timeout ?? 300_000, }); + const guardedFilesystem = createGuardedFilesystem(new DaytonaFilesystem(daytonaSandbox)); const workspace = new Workspace({ sandbox: daytonaSandbox, - filesystem: new DaytonaFilesystem(daytonaSandbox), + filesystem: guardedFilesystem.filesystem, }); await workspace.init(); @@ -267,6 +270,7 @@ export class BuilderSandboxFactory { return { workspace, + setFilesystemMutationGuard: guardedFilesystem.setMutationGuard, cleanup: async () => { await cleanupTrackedSandboxProcesses(workspace); await deleteSandbox(); @@ -301,9 +305,10 @@ export class BuilderSandboxFactory { }; try { + const guardedFilesystem = createGuardedFilesystem(new N8nSandboxFilesystem(sandbox)); const workspace = new Workspace({ sandbox, - filesystem: new N8nSandboxFilesystem(sandbox), + filesystem: guardedFilesystem.filesystem, }); await workspace.init(); @@ -319,6 +324,7 @@ export class BuilderSandboxFactory { return { workspace, + setFilesystemMutationGuard: guardedFilesystem.setMutationGuard, cleanup: async () => { await cleanupTrackedSandboxProcesses(workspace); await destroySandbox(); @@ -354,15 +360,17 @@ export class BuilderSandboxFactory { ): Promise { const dir = `./workspace-builders/${builderId}`; const sandbox = new LocalSandbox({ workingDirectory: dir }); + const guardedFilesystem = createGuardedFilesystem(new LocalFilesystem({ basePath: dir })); const workspace = new Workspace({ sandbox, - filesystem: new LocalFilesystem({ basePath: dir }), + filesystem: guardedFilesystem.filesystem, }); await workspace.init(); await setupSandboxWorkspace(workspace, context); return { workspace, + setFilesystemMutationGuard: guardedFilesystem.setMutationGuard, cleanup: async () => { await cleanupTrackedSandboxProcesses(workspace); // Local cleanup keeps the directory for debugging. diff --git a/packages/@n8n/instance-ai/src/workspace/guarded-filesystem.ts b/packages/@n8n/instance-ai/src/workspace/guarded-filesystem.ts new file mode 100644 index 00000000000..6a22288ef76 --- /dev/null +++ b/packages/@n8n/instance-ai/src/workspace/guarded-filesystem.ts @@ -0,0 +1,186 @@ +import type { + CopyOptions, + FileContent, + FileEntry, + FileStat, + FilesystemInfo, + ListOptions, + ProviderStatus, + ReadOptions, + RemoveOptions, + WorkspaceFilesystem, + WriteOptions, +} from '@mastra/core/workspace'; + +export interface FilesystemMutationBlocker { + guidance: string; +} + +export type FilesystemMutationGuard = () => FilesystemMutationBlocker | undefined; + +export type FilesystemMutationGuardSetter = (guard: FilesystemMutationGuard | undefined) => void; + +export function createGuardedFilesystem(filesystem: WorkspaceFilesystem): { + filesystem: WorkspaceFilesystem; + setMutationGuard: FilesystemMutationGuardSetter; +} { + const guarded = new GuardedFilesystem(filesystem); + return { + filesystem: guarded, + setMutationGuard: (guard) => guarded.setMutationGuard(guard), + }; +} + +class GuardedFilesystem implements WorkspaceFilesystem { + private mutationGuard: FilesystemMutationGuard | undefined; + + constructor(private readonly filesystem: WorkspaceFilesystem) {} + + get id() { + return this.filesystem.id; + } + + get name() { + return this.filesystem.name; + } + + get provider() { + return this.filesystem.provider; + } + + get status() { + return this.filesystem.status; + } + + set status(status: ProviderStatus) { + this.filesystem.status = status; + } + + get error() { + return this.filesystem.error; + } + + set error(error: string | undefined) { + this.filesystem.error = error; + } + + get readOnly() { + return this.filesystem.readOnly; + } + + get basePath() { + return this.filesystem.basePath; + } + + get icon() { + return this.filesystem.icon; + } + + get displayName() { + return this.filesystem.displayName; + } + + get description() { + return this.filesystem.description; + } + + setMutationGuard(guard: FilesystemMutationGuard | undefined): void { + this.mutationGuard = guard; + } + + async init(): Promise { + await this.filesystem.init?.(); + } + + async destroy(): Promise { + await this.filesystem.destroy?.(); + } + + async isReady(): Promise { + return (await this.filesystem.isReady?.()) ?? true; + } + + async getInfo(): Promise { + const info = await this.filesystem.getInfo?.(); + if (info) return info; + + const fallback: FilesystemInfo = { + id: this.id, + name: this.name, + provider: this.provider, + status: this.status, + }; + if (this.error !== undefined) fallback.error = this.error; + if (this.readOnly !== undefined) fallback.readOnly = this.readOnly; + if (this.icon !== undefined) fallback.icon = this.icon; + return fallback; + } + + getInstructions( + options?: Parameters>[0], + ): string { + return this.filesystem.getInstructions?.(options) ?? ''; + } + + async readFile(path: string, options?: ReadOptions): Promise { + return await this.filesystem.readFile(path, options); + } + + async writeFile(path: string, content: FileContent, options?: WriteOptions): Promise { + this.assertCanMutate(); + await this.filesystem.writeFile(path, content, options); + } + + async appendFile(path: string, content: FileContent): Promise { + this.assertCanMutate(); + await this.filesystem.appendFile(path, content); + } + + async deleteFile(path: string, options?: RemoveOptions): Promise { + this.assertCanMutate(); + await this.filesystem.deleteFile(path, options); + } + + async copyFile(src: string, dest: string, options?: CopyOptions): Promise { + this.assertCanMutate(); + await this.filesystem.copyFile(src, dest, options); + } + + async moveFile(src: string, dest: string, options?: CopyOptions): Promise { + this.assertCanMutate(); + await this.filesystem.moveFile(src, dest, options); + } + + async mkdir(path: string, options?: { recursive?: boolean }): Promise { + this.assertCanMutate(); + await this.filesystem.mkdir(path, options); + } + + async rmdir(path: string, options?: RemoveOptions): Promise { + this.assertCanMutate(); + await this.filesystem.rmdir(path, options); + } + + async readdir(path: string, options?: ListOptions): Promise { + return await this.filesystem.readdir(path, options); + } + + resolveAbsolutePath(path: string): string | undefined { + return this.filesystem.resolveAbsolutePath?.(path); + } + + async exists(path: string): Promise { + return await this.filesystem.exists(path); + } + + async stat(path: string): Promise { + return await this.filesystem.stat(path); + } + + private assertCanMutate(): void { + const blocker = this.mutationGuard?.(); + if (blocker) { + throw new Error(blocker.guidance); + } + } +}