feat(core): Make sandbox thread-scoped and lazy-initialize it on Instance AI (#30904)

This commit is contained in:
Albert Alises 2026-05-22 13:39:00 +02:00 committed by GitHub
parent affc3c1806
commit eba7d056c5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 2160 additions and 702 deletions

View File

@ -250,6 +250,34 @@ describe('createWorkspaceTools', () => {
expect(result).toEqual({ success: true });
});
it('execute_command handler includes sandbox default command environment', async () => {
const executeCommand = jest.fn().mockResolvedValue({
success: true,
exitCode: 0,
stdout: 'ok',
stderr: '',
executionTimeMs: 5,
});
const sandbox = makeFakeSandbox({
executeCommand,
getDefaultCommandEnv: () => ({ CUSTOM_ENV: 'enabled' }),
});
const tools = createWorkspaceTools({ sandbox });
const commandTool = tools.find((t) => t.name === 'workspace_execute_command')!;
const result = await commandTool.handler!(
{ command: 'node script.mjs', cwd: '/home/daytona/workspace' },
{} as never,
);
expect(executeCommand).toHaveBeenCalledWith('node script.mjs', undefined, {
cwd: '/home/daytona/workspace',
env: { CUSTOM_ENV: 'enabled' },
timeout: undefined,
});
expect(result).toMatchObject({ success: true, stdout: 'ok' });
});
it('list_files handler calls filesystem.readdir', async () => {
const fs = makeFakeFilesystem();
const tools = createWorkspaceTools({ filesystem: fs });

View File

@ -27,8 +27,10 @@ export function createExecuteCommandTool(sandbox: WorkspaceSandbox): BuiltTool {
if (!sandbox.executeCommand) {
throw new Error('Sandbox does not support command execution');
}
const env = sandbox.getDefaultCommandEnv?.();
const result = await sandbox.executeCommand(input.command, undefined, {
cwd: input.cwd,
...(env ? { env } : {}),
timeout: input.timeout,
});
return {

View File

@ -128,6 +128,7 @@ export interface WorkspaceSandbox {
readonly provider: string;
status: ProviderStatus;
getInstructions?(): string;
getDefaultCommandEnv?(): NodeJS.ProcessEnv;
executeCommand?(
command: string,
args?: string[],

View File

@ -158,13 +158,13 @@ export async function createInstanceAgent(options: CreateInstanceAgentOptions):
branchReadOnly: context.branchReadOnly,
});
// The orchestrator intentionally does not receive a workspace. Sandbox access
// is scoped to the workflow-builder subagent via `builderSandboxFactory`.
const telemetry = orchestrationContext?.tracing?.getTelemetry?.({
agentRole: 'orchestrator',
functionId: 'instance-ai.orchestrator',
executionMode: 'foreground',
});
// The orchestrator agent itself does not receive workspace tools. Sandbox access
// stays scoped to tools and sub-agents that request orchestrationContext.workspace.
const agent = new Agent('n8n-instance-agent')
.model(modelId)
.instructions(systemPrompt, {
@ -195,7 +195,6 @@ export async function createInstanceAgent(options: CreateInstanceAgentOptions):
: {}),
});
}
mergeTraceRunInputs(
orchestrationContext?.tracing?.actorRun,
buildAgentTraceInputs({

View File

@ -234,6 +234,9 @@ defineLazyExport('HAIKU_MODEL', () => loadEvalAgents().HAIKU_MODEL);
export type { SuspensionInfo, Resumable } from './utils/stream-helpers';
export { buildAgentTreeFromEvents, findAgentNodeInTree } from './utils/agent-tree';
export type { SandboxConfig } from './workspace/create-workspace';
export { createLazyRuntimeWorkspace } from './workspace/lazy-runtime-workspace';
export type { RuntimeWorkspaceResolver } from './workspace/lazy-runtime-workspace';
export { getWorkspaceRoot, setupSandboxWorkspace } from './workspace/sandbox-setup';
export type { BuilderWorkspace } from './workspace/builder-sandbox-factory';
export type BuilderSandboxFactory = BuilderSandboxFactoryMod.BuilderSandboxFactory;
export const createSandbox: typeof CreateWorkspaceMod.createSandbox = lazyFunction(

View File

@ -7,7 +7,6 @@ import {
import { UserError } from 'n8n-workflow';
import { executeTool } from '../../../__tests__/tool-test-utils';
import type { BuilderSandboxSession } from '../../../runtime/builder-sandbox-session-registry';
import { createToolRegistry } from '../../../tool-registry';
import type { OrchestrationContext, InstanceAiContext } from '../../../types';
import { createRemediation } from '../../../workflow-loop';
@ -32,6 +31,7 @@ const {
determineSetupRequirement,
determineVerificationReadiness,
getBuilderSessionMemory,
builderWorkflowWorkspaceLayout,
mergeLatestVerificationIntoOutcome,
settleMissingMainWorkflowSubmit,
supportingWorkflowIdsFromSubmitAttempts,
@ -107,6 +107,32 @@ function createSpawnableContext(
const MAIN_PATH = '/home/daytona/workspace/src/workflow.ts';
describe('builderWorkflowWorkspaceLayout', () => {
it('gives parallel work items isolated main workflow files in the shared workspace', () => {
const root = '/home/daytona/workspace';
const first = builderWorkflowWorkspaceLayout(root, 'wi_fetch_customers');
const second = builderWorkflowWorkspaceLayout(root, 'wi_send_report');
expect(first.mainWorkflowPath).toBe(`${first.workItemRoot}/src/workflow.ts`);
expect(second.mainWorkflowPath).toBe(`${second.workItemRoot}/src/workflow.ts`);
expect(first.mainWorkflowPath).not.toBe(second.mainWorkflowPath);
expect(first.chunksDir).not.toBe(second.chunksDir);
expect(first.tsconfigPath).toBe(`${first.workItemRoot}/tsconfig.json`);
expect(first.relativeMainWorkflowPath).not.toContain('..');
});
it('sanitizes work item ids before using them in workspace paths', () => {
const layout = builderWorkflowWorkspaceLayout(
'/home/daytona/workspace',
'run:one/../../workflow',
);
expect(layout.relativeMainWorkflowPath).toMatch(
/^builder-work-items\/run-one-workflow-[a-f0-9]{8}\/src\/workflow\.ts$/,
);
});
});
describe('buildWarmBuilderFollowUp', () => {
it('keeps the detached builder verification contract in warm follow-ups', () => {
const briefing = buildWarmBuilderFollowUp({
@ -127,22 +153,20 @@ describe('buildWarmBuilderFollowUp', () => {
});
describe('getBuilderSessionMemory', () => {
const session = { sessionId: 'builder-session-1' } as BuilderSandboxSession;
it('uses memory for retained builder sessions', () => {
it('uses memory when the builder runs in the shared workspace', () => {
const memory = {} as OrchestrationContext['memory'];
expect(getBuilderSessionMemory({ memory }, session)).toBe(memory);
expect(getBuilderSessionMemory({ memory }, true)).toBe(memory);
});
it('skips memory when there is no retained builder session', () => {
it('skips memory when the builder falls back to tool mode', () => {
const memory = {} as OrchestrationContext['memory'];
expect(getBuilderSessionMemory({ memory }, undefined)).toBeUndefined();
expect(getBuilderSessionMemory({ memory }, false)).toBeUndefined();
});
it('skips memory when the context has no memory store', () => {
expect(getBuilderSessionMemory({}, session)).toBeUndefined();
expect(getBuilderSessionMemory({}, true)).toBeUndefined();
});
});

View File

@ -105,4 +105,31 @@ describe('credential guardrail prompts', () => {
expect(prompt).not.toContain('workflows(action="publish")');
expect(prompt).not.toContain('Do NOT publish');
});
it('points sandbox builders at the task-specific workflow and chunks paths', () => {
const prompt = createSandboxBuilderAgentPrompt('/tmp/workspace', {
mainWorkflowPath: '/tmp/workspace/builder-work-items/wi-one/src/workflow.ts',
sourceDir: '/tmp/workspace/builder-work-items/wi-one/src',
chunksDir: '/tmp/workspace/builder-work-items/wi-one/chunks',
tsconfigPath: '/tmp/workspace/builder-work-items/wi-one/tsconfig.json',
});
expect(prompt).toContain(
'Your active main workflow file is `/tmp/workspace/builder-work-items/wi-one/src/workflow.ts`',
);
expect(prompt).toContain(
'Use `/tmp/workspace/builder-work-items/wi-one/chunks/` for supporting chunk files',
);
expect(prompt).toContain(
'execute_command: cd /tmp/workspace && npx tsc --noEmit --project /tmp/workspace/builder-work-items/wi-one/tsconfig.json 2>&1',
);
expect(prompt).not.toContain('Write workflow code to `/tmp/workspace/src/workflow.ts`');
});
it('uses the provided workspace root for fallback tsc validation', () => {
const prompt = createSandboxBuilderAgentPrompt('/tmp/custom-workspace');
expect(prompt).toContain('execute_command: cd /tmp/custom-workspace && npx tsc --noEmit 2>&1');
expect(prompt).not.toContain('execute_command: cd ~/workspace && npx tsc --noEmit 2>&1');
});
});

View File

@ -157,7 +157,32 @@ ${SDK_RULES_AND_PATTERNS_TOOL}
// ── Sandbox-based builder prompt ─────────────────────────────────────────────
export function createSandboxBuilderAgentPrompt(workspaceRoot: string): string {
export interface SandboxBuilderWorkspaceLayout {
mainWorkflowPath?: string;
sourceDir?: string;
chunksDir?: string;
tsconfigPath?: string;
}
function relativeToWorkspace(workspaceRoot: string, filePath: string): string {
return filePath.startsWith(`${workspaceRoot}/`)
? filePath.slice(workspaceRoot.length + 1)
: filePath;
}
export function createSandboxBuilderAgentPrompt(
workspaceRoot: string,
layout: SandboxBuilderWorkspaceLayout = {},
): string {
const sourceDir = layout.sourceDir ?? `${workspaceRoot}/src`;
const chunksDir = layout.chunksDir ?? `${workspaceRoot}/chunks`;
const mainWorkflowPath = layout.mainWorkflowPath ?? `${sourceDir}/workflow.ts`;
const tsconfigCommand = layout.tsconfigPath
? `cd ${workspaceRoot} && npx tsc --noEmit --project ${layout.tsconfigPath} 2>&1`
: `cd ${workspaceRoot} && npx tsc --noEmit 2>&1`;
const sourceDirLabel = relativeToWorkspace(workspaceRoot, sourceDir);
const chunksDirLabel = relativeToWorkspace(workspaceRoot, chunksDir);
return `You are an expert n8n workflow builder working inside a sandbox with real TypeScript tooling. You write workflow code as files and use \`tsc\` for validation.
${BUILDER_OUTPUT_DISCIPLINE}
@ -174,18 +199,22 @@ ${workspaceRoot}/
workflows/ # existing n8n workflows as JSON
node-types/
index.txt # searchable catalog: nodeType | displayName | description | version
src/
workflow.ts # write your main workflow code here
chunks/
*.ts # reusable node/workflow modules
${sourceDirLabel}/
workflow.ts # write this task's main workflow code here
${chunksDirLabel}/
*.ts # reusable node/workflow modules for this task
\`\`\`
Your active main workflow file is \`${mainWorkflowPath}\`.
Use \`${chunksDir}/\` for supporting chunk files in this task.
Do not write this task's workflow code into any other builder task directory.
## Modular Code
For complex workflows, split reusable pieces into separate files in \`chunks/\`:
For complex workflows, split reusable pieces into separate files in \`${chunksDir}/\`:
\`\`\`typescript
// ${workspaceRoot}/chunks/weather.ts
// ${chunksDir}/weather.ts
import { node } from '@n8n/workflow-sdk';
export const weatherNode = node({
@ -200,7 +229,7 @@ export const weatherNode = node({
\`\`\`
\`\`\`typescript
// ${workspaceRoot}/src/workflow.ts
// ${mainWorkflowPath}
import { workflow, trigger } from '@n8n/workflow-sdk';
import { weatherNode } from '../chunks/weather';
@ -210,7 +239,7 @@ export default workflow('my-workflow', 'My Workflow')
.to(weatherNode);
\`\`\`
The \`submit-workflow\` tool executes your code natively in the sandbox via tsx — local imports resolve naturally via Node.js module resolution. Both \`src/\` and \`chunks/\` files are included in tsc validation.
The \`submit-workflow\` tool executes your code natively in the sandbox via tsx — local imports resolve naturally via Node.js module resolution. Both the active source and chunks directories are included in tsc validation.
## Compositional Workflow Pattern
@ -221,7 +250,7 @@ For complex workflows, decompose into standalone sub-workflows (chunks) that can
Each chunk uses \`executeWorkflowTrigger\` (v1.1) with explicit input schema:
\`\`\`typescript
// ${workspaceRoot}/chunks/weather-data.ts
// ${chunksDir}/weather-data.ts
import { workflow, node, trigger } from '@n8n/workflow-sdk';
const inputTrigger = trigger({
@ -275,7 +304,7 @@ Supported input types: \`string\`, \`number\`, \`boolean\`, \`array\`, \`object\
Reference the submitted chunk by its workflow ID using \`executeWorkflow\`:
\`\`\`typescript
// ${workspaceRoot}/src/workflow.ts
// ${mainWorkflowPath}
import { workflow, node, trigger } from '@n8n/workflow-sdk';
const scheduleTrigger = trigger({
@ -311,7 +340,7 @@ Replace \`CHUNK_WORKFLOW_ID\` with the actual ID returned by \`submit-workflow\`
### When to use this pattern
- **Simple workflows** (< 5 nodes): Write everything in \`src/workflow.ts\` directly.
- **Simple workflows** (< 5 nodes): Write everything in \`${mainWorkflowPath}\` directly.
- **Complex workflows** (5+ nodes, multiple integrations): Decompose into chunks.
Build, test, and compose. Each chunk is reusable across workflows.
@ -346,7 +375,7 @@ ${ASK_USER_FALLBACK}
## Sandbox-Specific Rules
- **Full TypeScript/JavaScript support** you can use any valid TS/JS: template literals, array methods (\`.map\`, \`.filter\`, \`.join\`), string methods (\`.trim\`, \`.split\`), loops, functions, \`readFileSync\`, etc. The code is executed natively via tsx.
- **For large HTML, use the file-based pattern.** Write HTML to \`chunks/page.html\`, then \`readFileSync\` + \`JSON.stringify\` in your SDK code. NEVER embed large HTML directly in jsCode — it will break. See the web_app_pattern section.
- **For large HTML, use the file-based pattern.** Write HTML to \`${chunksDir}/page.html\`, then \`readFileSync\` + \`JSON.stringify\` in your SDK code. NEVER embed large HTML directly in jsCode — it will break. See the web_app_pattern section.
- **Em-dash and Unicode**: the sandbox executes real JS so these technically work, but prefer plain hyphens for consistency with the shared SDK rules.
## Credentials (sandbox mode)
@ -410,9 +439,9 @@ n8n normalizes column names to snake_case (e.g., \`dayName\` → \`day_name\`).
\`\`\`
Each line in \`examples/index.txt\` is \`filename | name | nodes | tags | source-id\`. Use the example as a reference for **structure** (which credential type each node uses, how nodes are wired, where sub-nodes attach to an agent, where sticky notes go) — not as a verbatim copy. The user's request will rarely match an example one-to-one.
The \`examples/\` directory is **read-only reference**. Never edit files there; \`src/\` and \`chunks/\` are your scratch.
The \`examples/\` directory is **read-only reference**. Never edit files there; \`${sourceDir}/\` and \`${chunksDir}/\` are your scratch.
Examples use \`newCredential('Name', 'id')\` for clarity. When you copy a pattern into \`src/workflow.ts\`, replace those calls with raw \`{ id, name }\` from \`credentials(action="list")\` per the rules above.
Examples use \`newCredential('Name', 'id')\` for clarity. When you copy a pattern into \`${mainWorkflowPath}\`, replace those calls with raw \`{ id, name }\` from \`credentials(action="list")\` per the rules above.
If grep returns nothing, build from scratch. **Do not fabricate examples that do not exist.**
@ -427,13 +456,13 @@ n8n normalizes column names to snake_case (e.g., \`dayName\` → \`day_name\`).
- **If \`explore-resources\` returns more than one match and the user did not name a specific one, use \`placeholder('Select <resource>')\` for that parameter** (e.g. \`placeholder('Select a calendar')\`, \`placeholder('Select a Slack channel')\`). Picking one silently is a guess; after the build, the inline setup card in the AI Assistant panel surfaces placeholders so the user can choose. Only pick a single match without prompting.
- If the resource can't be created via n8n (e.g., Slack channels), explain clearly in your summary what the user needs to set up.
5. **Write workflow code** to \`${workspaceRoot}/src/workflow.ts\`.
5. **Write workflow code** to \`${mainWorkflowPath}\`.
6. **Trace wiring before declaring done**: For workflows containing IF, Switch, or Merge nodes, trace each branch from its source to its target confirm IF outputs are wired with \`.onTrue()\`/\`.onFalse()\`, every Switch rule output is wired by zero-based \`.onCase(index, target)\`, and the Merge mode matches the data shape. Read each node's \`@builderHint\` for selection criteria.
7. **Validate with tsc**: Run the TypeScript compiler for real type checking:
\`\`\`
execute_command: cd ~/workspace && npx tsc --noEmit 2>&1
execute_command: ${tsconfigCommand}
\`\`\`
Fix any errors using \`edit_file\` (with absolute path) to update the code, then re-run tsc. Iterate until clean.
**Important**: If tsc reports errors you cannot resolve after 2 attempts, skip tsc and proceed to submit-workflow. The submit tool has its own validation.
@ -454,11 +483,11 @@ Follow the **Compositional Workflow Pattern** above. The process becomes:
3. **Resolve real resource IDs** (same as above call \`nodes(action="explore-resources")\` for EVERY parameter with \`searchListMethod\` or \`loadOptionsMethod\`). Never assume IDs like "primary" or "default". If a resource doesn't exist, use a placeholder unless the user explicitly asked you to create that resource.
4. **Decompose** the workflow into logical chunks. Each chunk is a standalone sub-workflow with 2-4 nodes covering one capability (e.g., "fetch and format weather data", "generate AI recommendation", "store to data table").
5. **For each chunk**:
a. Write the chunk to \`${workspaceRoot}/chunks/<name>.ts\` with an \`executeWorkflowTrigger\` and explicit input schema.
a. Write the chunk to \`${chunksDir}/<name>.ts\` with an \`executeWorkflowTrigger\` and explicit input schema.
b. Run tsc.
c. Submit the chunk: \`submit-workflow\` with \`filePath\` pointing to the chunk file. Test via \`executions(action="run")\`.
d. Fix if needed (max 2 submission fix attempts per chunk).
6. **Write the main workflow** in \`${workspaceRoot}/src/workflow.ts\` that composes chunks via \`executeWorkflow\` nodes, referencing each chunk's workflow ID.
6. **Write the main workflow** in \`${mainWorkflowPath}\` that composes chunks via \`executeWorkflow\` nodes, referencing each chunk's workflow ID.
7. **Trace wiring before declaring done**: For workflows containing IF, Switch, or Merge nodes, trace each branch from its source to its target confirm IF outputs are wired with \`.onTrue()\`/\`.onFalse()\`, every Switch rule output is wired by zero-based \`.onCase(index, target)\`, and the Merge mode matches the data shape. Read each node's \`@builderHint\` for selection criteria.
8. **Submit** the main workflow.
9. **Done**: Output ONE sentence summarizing what was built, including the workflow ID and any known issues.
@ -466,7 +495,7 @@ Follow the **Compositional Workflow Pattern** above. The process becomes:
Do NOT produce visible output until the final step. All reasoning happens internally.
## Modifying Existing Workflows
When modifying an existing workflow, the current code is **already pre-loaded** into \`${workspaceRoot}/src/workflow.ts\` with SDK imports.
When modifying an existing workflow, the current code is **already pre-loaded** into \`${mainWorkflowPath}\` with SDK imports.
**Pre-flight check before any edit**: If the change introduces a node type not already in the file, or touches parameter values you haven't just looked up (model IDs, RLC values, enum selections, credential types, versions, etc.), call \`nodes(action="type-definition")\` first. Read \`@builderHint\`, \`@default\`, \`@searchListMethod\`, and \`@loadOptionsMethod\` from the output.

View File

@ -2,6 +2,7 @@ import { createRemediation } from '../../../workflow-loop/remediation';
import type { WorkflowLoopState } from '../../../workflow-loop/workflow-loop-state';
import {
createPreSaveBudgetTracker,
withDefaultWorkflowFilePath,
wrapSubmitExecuteWithIdentity,
} from '../submit-workflow-identity';
import type { SubmitWorkflowInput, SubmitWorkflowOutput } from '../submit-workflow.tool';
@ -9,6 +10,7 @@ import type { SubmitWorkflowInput, SubmitWorkflowOutput } from '../submit-workfl
const ROOT = '/home/daytona/workspace';
const MAIN_PATH = `${ROOT}/src/workflow.ts`;
const CHUNK_PATH = `${ROOT}/src/chunk.ts`;
const TASK_MAIN_PATH = `${ROOT}/builder-work-items/wi-one/src/workflow.ts`;
function resolvePath(rawFilePath: string | undefined): string {
if (!rawFilePath) return MAIN_PATH;
@ -41,6 +43,24 @@ function makeUnderlying(opts: { idPrefix?: string; gate?: Promise<void> } = {})
return { execute, calls };
}
describe('withDefaultWorkflowFilePath', () => {
it('uses the task main workflow file when submit-workflow omits filePath', () => {
expect(withDefaultWorkflowFilePath({ name: 'Workflow' }, TASK_MAIN_PATH)).toEqual({
name: 'Workflow',
filePath: TASK_MAIN_PATH,
});
});
it('preserves explicit filePath values for chunks and follow-up submits', () => {
expect(
withDefaultWorkflowFilePath({ filePath: CHUNK_PATH, name: 'Chunk' }, TASK_MAIN_PATH),
).toEqual({
filePath: CHUNK_PATH,
name: 'Chunk',
});
});
});
describe('wrapSubmitExecuteWithIdentity', () => {
it('parallel submits for the same filePath produce one create and N-1 updates sharing the workflowId', async () => {
let release: () => void = () => {};

View File

@ -238,6 +238,13 @@ export function wrapSubmitExecuteWithIdentity(
};
}
export function withDefaultWorkflowFilePath(
input: SubmitWorkflowInput,
defaultFilePath: string | undefined,
): SubmitWorkflowInput {
return defaultFilePath && !input.filePath ? { ...input, filePath: defaultFilePath } : input;
}
/**
* Build a submit-workflow tool wired with identity enforcement.
* Convenience factory used at the builder-agent callsite.
@ -248,6 +255,7 @@ export function createIdentityEnforcedSubmitWorkflowTool(args: {
credentialMap?: CredentialMap;
onAttempt: (attempt: SubmitWorkflowAttempt) => Promise<void> | void;
root: string;
defaultFilePath?: string;
currentRunId?: string;
getWorkflowLoopState?: () => Promise<WorkflowLoopState | undefined>;
onGuardFired?: SubmitGuardOptions['onGuardFired'];
@ -269,7 +277,10 @@ export function createIdentityEnforcedSubmitWorkflowTool(args: {
const wrappedExecute = wrapSubmitExecuteWithIdentity(
underlyingExecute,
(rawFilePath) => resolveSandboxWorkflowFilePath(rawFilePath, args.root),
(rawFilePath) =>
rawFilePath
? resolveSandboxWorkflowFilePath(rawFilePath, args.root)
: (args.defaultFilePath ?? resolveSandboxWorkflowFilePath(rawFilePath, args.root)),
{
budgetTracker,
currentRunId: args.currentRunId,
@ -282,6 +293,9 @@ export function createIdentityEnforcedSubmitWorkflowTool(args: {
.description(underlying.description)
.input(submitWorkflowInputSchema)
.output(submitWorkflowOutputSchema)
.handler(wrappedExecute)
.handler(
async (input) =>
await wrappedExecute(withDefaultWorkflowFilePath(input, args.defaultFilePath)),
)
.build();
}

View File

@ -168,7 +168,7 @@ export const submitWorkflowInputSchema = z.object({
filePath: z
.string()
.optional()
.describe('Path to the TypeScript workflow file (default: ~/workspace/src/workflow.ts)'),
.describe('Path to the TypeScript workflow file (defaults to the builder task main file)'),
workflowId: z
.string()
.optional()

View File

@ -32,7 +32,6 @@ import type { DomainAccessTracker } from './domain-access/domain-access-tracker'
import type { InstanceAiEventBus } from './event-bus/event-bus.interface';
import type { Logger } from './logger';
import type { McpClientManager } from './mcp/mcp-client-manager';
import type { BuilderSandboxSessionRegistry } from './runtime/builder-sandbox-session-registry';
import type { IterationLog } from './storage/iteration-log';
import type { IdRemapper, TraceIndex, TraceWriter } from './tracing/trace-replay';
import type {
@ -41,7 +40,6 @@ import type {
WorkflowLoopAction,
WorkflowLoopState,
} from './workflow-loop/workflow-loop-state';
import type { BuilderSandboxFactory } from './workspace/builder-sandbox-factory';
// ── Data shapes ──────────────────────────────────────────────────────────────
@ -1072,12 +1070,8 @@ export interface OrchestrationContext {
plannedTaskService?: PlannedTaskService;
/** Run one scheduler pass after plan/task state changes. */
schedulePlannedTasks?: () => Promise<void>;
/** Sandbox workspace — when present, enables sandbox-based workflow building */
/** Shared runtime workspace for the current orchestration context. */
workspace?: Workspace;
/** Factory for creating per-builder ephemeral sandboxes from a pre-warmed snapshot */
builderSandboxFactory?: BuilderSandboxFactory;
/** Process-local registry for retaining recently finished builder sandboxes. */
builderSandboxSessionRegistry?: BuilderSandboxSessionRegistry;
/** Directories containing node type definition files (.ts) for materializing into sandbox */
nodeDefinitionDirs?: string[];
/** Native memory store — used to retrieve thread message history for sub-agents. */
@ -1139,13 +1133,6 @@ export interface CreateInstanceAgentOptions {
* Intended for tests and fallback paths that need the full toolset visible immediately.
*/
disableDeferredTools?: boolean;
/**
* @deprecated Ignored by the orchestrator. Passing a workspace here used to auto-register
* workspace tools on the orchestrator, which the LLM abused as a `sleep` primitive
* and mis-routed for build-task polling. Sandbox access is now scoped to the workflow-builder
* subagent via `builderSandboxFactory`; `orchestrationContext.workspace` still flows to it.
*/
workspace?: Workspace;
/** IANA time zone for the current user (e.g. "Europe/Helsinki"). Falls back to instance default. */
timeZone?: string;
}

View File

@ -33,6 +33,8 @@ describe('createSandbox', () => {
const config: SandboxConfig = {
enabled: true,
provider: 'daytona',
id: 'instance-ai-thread-thread-1',
name: 'instance-ai-thread-thread-1',
daytonaApiUrl: 'https://api.daytona.io',
daytonaApiKey: 'test-key',
image: 'node:20',
@ -44,6 +46,8 @@ describe('createSandbox', () => {
expect(result).toBeInstanceOf(DaytonaSandbox);
expect(getPrivateOptions(result)).toEqual(
expect.objectContaining({
id: 'instance-ai-thread-thread-1',
name: 'instance-ai-thread-thread-1',
apiKey: 'test-key',
apiUrl: 'https://api.daytona.io',
image: 'node:20',

View File

@ -0,0 +1,187 @@
import {
Workspace,
type CommandResult,
type WorkspaceFilesystem,
type WorkspaceSandbox,
} from '@n8n/agents';
import { createLazyRuntimeWorkspace } from '../lazy-runtime-workspace';
function createMockWorkspace() {
const executeCommand = jest.fn<
Promise<CommandResult>,
Parameters<NonNullable<WorkspaceSandbox['executeCommand']>>
>(
async (_command, _args, options) =>
await Promise.resolve({
success: true,
exitCode: 0,
stdout: options?.env?.CUSTOM_ENV ?? '',
stderr: '',
executionTimeMs: 1,
}),
);
const filesystem: WorkspaceFilesystem = {
id: 'fs',
name: 'Filesystem',
provider: 'test',
status: 'ready',
destroy: jest.fn(async () => {
filesystem.status = 'destroyed';
await Promise.resolve();
}),
getInstructions: jest.fn(() => 'Real filesystem instructions.'),
readFile: jest.fn(async () => await Promise.resolve('hello')),
writeFile: jest.fn(async () => await Promise.resolve()),
appendFile: jest.fn(async () => await Promise.resolve()),
deleteFile: jest.fn(async () => await Promise.resolve()),
copyFile: jest.fn(async () => await Promise.resolve()),
moveFile: jest.fn(async () => await Promise.resolve()),
mkdir: jest.fn(async () => await Promise.resolve()),
rmdir: jest.fn(async () => await Promise.resolve()),
readdir: jest.fn(async () => await Promise.resolve([])),
exists: jest.fn(async () => await Promise.resolve(true)),
stat: jest.fn(
async (path: string) =>
await Promise.resolve({
name: path,
path,
type: 'file' as const,
size: 5,
createdAt: new Date('2026-01-01T00:00:00.000Z'),
modifiedAt: new Date('2026-01-01T00:00:00.000Z'),
}),
),
};
const sandbox: WorkspaceSandbox = {
id: 'sandbox',
name: 'Sandbox',
provider: 'test',
status: 'running',
stop: jest.fn(async () => {
sandbox.status = 'stopped';
await Promise.resolve();
}),
destroy: jest.fn(async () => {
sandbox.status = 'destroyed';
await Promise.resolve();
}),
getInstructions: jest.fn(() => 'Real sandbox instructions.'),
getDefaultCommandEnv: jest.fn(() => ({ CUSTOM_ENV: 'enabled' })),
executeCommand,
};
return {
workspace: new Workspace({ filesystem, sandbox }),
filesystem,
sandbox,
executeCommand,
};
}
describe('createLazyRuntimeWorkspace', () => {
it('advertises workspace tools without creating the real workspace', async () => {
const { workspace } = createMockWorkspace();
const ensureWorkspace = jest.fn(async () => await Promise.resolve(workspace));
const lazyWorkspace = createLazyRuntimeWorkspace({ ensureWorkspace });
const tools = lazyWorkspace.getTools();
lazyWorkspace.getInstructions();
expect(ensureWorkspace).not.toHaveBeenCalled();
expect(tools.some((tool) => tool.name === 'workspace_read_file')).toBe(true);
expect(tools.some((tool) => tool.name === 'workspace_execute_command')).toBe(true);
const readFile = tools.find((tool) => tool.name === 'workspace_read_file');
await readFile?.handler?.({ path: '/workspace/report.md' }, {});
expect(ensureWorkspace).toHaveBeenCalledTimes(1);
});
it('merges sandbox default env after the real workspace is created', async () => {
const { workspace, executeCommand } = createMockWorkspace();
const ensureWorkspace = jest.fn(async () => await Promise.resolve(workspace));
const lazyWorkspace = createLazyRuntimeWorkspace({ ensureWorkspace });
const executeCommandTool = lazyWorkspace
.getTools()
.find((tool) => tool.name === 'workspace_execute_command');
const result = await executeCommandTool?.handler?.({ command: 'echo $CUSTOM_ENV' }, {});
expect(result).toMatchObject({ stdout: 'enabled' });
expect(executeCommand.mock.calls[0]?.[0]).toBe('echo $CUSTOM_ENV');
expect(executeCommand.mock.calls[0]?.[1]).toEqual([]);
expect(executeCommand.mock.calls[0]?.[2]?.env).toMatchObject({
CUSTOM_ENV: 'enabled',
});
});
it('retries workspace creation after the first lazy initialization fails', async () => {
const { workspace } = createMockWorkspace();
const ensureWorkspace = jest
.fn()
.mockRejectedValueOnce(new Error('setup failed'))
.mockResolvedValueOnce(workspace);
const lazyWorkspace = createLazyRuntimeWorkspace({ ensureWorkspace });
const readFile = lazyWorkspace.getTools().find((tool) => tool.name === 'workspace_read_file');
await expect(readFile?.handler?.({ path: '/workspace/report.md' }, {})).rejects.toThrow(
'setup failed',
);
await expect(readFile?.handler?.({ path: '/workspace/report.md' }, {})).resolves.toEqual({
content: 'hello',
});
expect(ensureWorkspace).toHaveBeenCalledTimes(2);
});
it('retries workspace creation after the first lazy initialization returns unavailable', async () => {
const { workspace } = createMockWorkspace();
const ensureWorkspace = jest
.fn()
.mockResolvedValueOnce(undefined)
.mockResolvedValueOnce(workspace);
const lazyWorkspace = createLazyRuntimeWorkspace({ ensureWorkspace });
const readFile = lazyWorkspace.getTools().find((tool) => tool.name === 'workspace_read_file');
await expect(readFile?.handler?.({ path: '/workspace/report.md' }, {})).rejects.toThrow(
'Instance AI runtime workspace is unavailable.',
);
await expect(readFile?.handler?.({ path: '/workspace/report.md' }, {})).resolves.toEqual({
content: 'hello',
});
expect(ensureWorkspace).toHaveBeenCalledTimes(2);
});
it('reflects resolved provider statuses and instructions', async () => {
const { workspace } = createMockWorkspace();
const ensureWorkspace = jest.fn(async () => await Promise.resolve(workspace));
const lazyWorkspace = createLazyRuntimeWorkspace({ ensureWorkspace });
expect(lazyWorkspace.filesystem?.status).toBe('pending');
expect(lazyWorkspace.sandbox?.status).toBe('pending');
expect(lazyWorkspace.getInstructions()).toContain('create the runtime workspace on first use');
await lazyWorkspace.filesystem?.readFile('/workspace/report.md');
expect(lazyWorkspace.filesystem?.status).toBe('ready');
expect(lazyWorkspace.sandbox?.status).toBe('running');
expect(lazyWorkspace.getInstructions()).toContain('Real sandbox instructions.');
expect(lazyWorkspace.getInstructions()).toContain('Real filesystem instructions.');
});
it('destroys the resolved workspace when the lazy workspace is destroyed', async () => {
const { workspace, filesystem, sandbox } = createMockWorkspace();
const ensureWorkspace = jest.fn(async () => await Promise.resolve(workspace));
const lazyWorkspace = createLazyRuntimeWorkspace({ ensureWorkspace });
await lazyWorkspace.filesystem?.readFile('/workspace/report.md');
await lazyWorkspace.destroy();
expect(sandbox.destroy).toHaveBeenCalledTimes(1);
expect(filesystem.destroy).toHaveBeenCalledTimes(1);
expect(lazyWorkspace.filesystem?.status).toBe('destroyed');
expect(lazyWorkspace.sandbox?.status).toBe('destroyed');
});
});

View File

@ -3,9 +3,14 @@ import { jsonParse } from 'n8n-workflow';
import type { InstanceAiContext, SearchableNodeDescription } from '../../types';
import type { SandboxWorkspace } from '../sandbox-fs';
import type { setupSandboxWorkspace as setupSandboxWorkspaceFunction } from '../sandbox-setup';
import { formatNodeCatalogLine } from '../sandbox-setup';
import { formatNodeCatalogLine, getWorkspaceRoot } from '../sandbox-setup';
type SetupSandboxWorkspace = typeof setupSandboxWorkspaceFunction;
type LinkWorkspaceSdkIfEnabled = (
workspace: SandboxWorkspace,
root: string,
logger?: { error: jest.Mock; info: jest.Mock },
) => Promise<void>;
type RunInSandboxMock = jest.Mock<
Promise<{ exitCode: number; stdout: string; stderr: string }>,
[SandboxWorkspace, string, string?]
@ -19,12 +24,13 @@ function createSetupContext(): InstanceAiContext {
},
workflowService: {
list: jest.fn().mockResolvedValue([]),
get: jest.fn(),
},
} as unknown as InstanceAiContext;
}
function createLocalWorkspace(
writeFile: jest.Mock<Promise<void>, [string, string, { recursive?: boolean }?]>,
writeFile: jest.Mock<Promise<void>, [string, string | Buffer, { recursive?: boolean }?]>,
mkdir?: jest.Mock<Promise<void>, [string, { recursive?: boolean }?]>,
): SandboxWorkspace {
return {
@ -45,6 +51,12 @@ function loadSetupSandboxWorkspaceWithFsMocks(
jest.doMock('../sandbox-fs', () => ({
runInSandbox,
readFileViaSandbox,
writeFileViaSandbox: async (workspace: SandboxWorkspace, path: string) => {
const result = await runInSandbox(workspace, `write '${path}'`);
if (result.exitCode !== 0) {
throw new Error(`Failed to write file ${path}: ${result.stderr}`);
}
},
escapeSingleQuotes: (value: string) => value.replace(/'/g, "'\\''"),
}));
@ -60,6 +72,34 @@ function loadSetupSandboxWorkspaceWithFsMocks(
return sandboxSetup.setupSandboxWorkspace;
}
function loadLinkWorkspaceSdkWithMocks(
packWorkspaceSdk: jest.Mock,
runInSandbox: RunInSandboxMock,
): LinkWorkspaceSdkIfEnabled {
jest.resetModules();
jest.doMock('../pack-workspace-sdk', () => ({
isLinkWorkspaceSdkEnabled: () => true,
packWorkspaceSdk,
}));
jest.doMock('../sandbox-fs', () => ({
runInSandbox,
readFileViaSandbox: jest.fn(),
writeFileViaSandbox: jest.fn(),
escapeSingleQuotes: (value: string) => value.replace(/'/g, "'\\''"),
}));
let sandboxSetup: { linkWorkspaceSdkIfEnabled: LinkWorkspaceSdkIfEnabled } | undefined;
jest.isolateModules(() => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
sandboxSetup = require('../sandbox-setup') as {
linkWorkspaceSdkIfEnabled: LinkWorkspaceSdkIfEnabled;
};
});
if (!sandboxSetup) throw new Error('Failed to load sandbox setup module');
return sandboxSetup.linkWorkspaceSdkIfEnabled;
}
function loadSandboxPackageJson(linkSdk: boolean): {
dependencies: Record<string, string>;
devDependencies: Record<string, string>;
@ -131,7 +171,7 @@ describe('setupSandboxWorkspace', () => {
runInSandbox,
readFileViaSandbox,
);
const writeFile = jest.fn<Promise<void>, [string, string, { recursive?: boolean }?]>(
const writeFile = jest.fn<Promise<void>, [string, string | Buffer, { recursive?: boolean }?]>(
async () => {},
);
@ -161,7 +201,7 @@ describe('setupSandboxWorkspace', () => {
runInSandbox,
readFileViaSandbox,
);
const writeFile = jest.fn<Promise<void>, [string, string, { recursive?: boolean }?]>(
const writeFile = jest.fn<Promise<void>, [string, string | Buffer, { recursive?: boolean }?]>(
async () => {},
);
const mkdir = jest.fn<Promise<void>, [string, { recursive?: boolean }?]>(async () => {});
@ -190,7 +230,7 @@ describe('setupSandboxWorkspace', () => {
runInSandbox,
readFileViaSandbox,
);
const writeFile = jest.fn<Promise<void>, [string, string, { recursive?: boolean }?]>(
const writeFile = jest.fn<Promise<void>, [string, string | Buffer, { recursive?: boolean }?]>(
async () => {},
);
@ -201,6 +241,37 @@ describe('setupSandboxWorkspace', () => {
expect(writtenPaths.some((p) => /^\/sandbox\/examples\/.+\.ts$/.test(p))).toBe(true);
});
it('rejects setup file paths that escape the workspace root', async () => {
const runInSandbox: RunInSandboxMock = jest.fn<
Promise<{ exitCode: number; stdout: string; stderr: string }>,
[SandboxWorkspace, string, string?]
>();
runInSandbox.mockResolvedValue({ exitCode: 0, stdout: '', stderr: '' });
const readFileViaSandbox: ReadFileViaSandboxMock = jest.fn<
Promise<string | null>,
[SandboxWorkspace, string]
>();
readFileViaSandbox.mockResolvedValue(null);
const setupSandboxWorkspace = loadSetupSandboxWorkspaceWithFsMocks(
runInSandbox,
readFileViaSandbox,
);
const writeFile = jest.fn<Promise<void>, [string, string | Buffer, { recursive?: boolean }?]>(
async () => {},
);
const context = createSetupContext();
const workflowService = context.workflowService as unknown as {
list: jest.Mock<Promise<Array<{ id: string }>>, [{ limit: number }]>;
get: jest.Mock<Promise<Record<string, unknown>>, [string]>;
};
workflowService.list.mockResolvedValue([{ id: '../escape' }]);
workflowService.get.mockResolvedValue({ id: '../escape' });
await expect(setupSandboxWorkspace(createLocalWorkspace(writeFile), context)).rejects.toThrow(
'Sandbox workspace setup failed during write-workspace-files',
);
});
it('does not write the initialized marker when npm install fails', async () => {
const runInSandbox: RunInSandboxMock = jest.fn<
Promise<{ exitCode: number; stdout: string; stderr: string }>,
@ -216,7 +287,7 @@ describe('setupSandboxWorkspace', () => {
runInSandbox,
readFileViaSandbox,
);
const writeFile = jest.fn<Promise<void>, [string, string, { recursive?: boolean }?]>(
const writeFile = jest.fn<Promise<void>, [string, string | Buffer, { recursive?: boolean }?]>(
async () => {},
);
@ -230,6 +301,159 @@ describe('setupSandboxWorkspace', () => {
{ recursive: true },
]);
});
it('uses command fallback when a filesystem marker write fails', async () => {
const runInSandbox: RunInSandboxMock = jest.fn<
Promise<{ exitCode: number; stdout: string; stderr: string }>,
[SandboxWorkspace, string, string?]
>();
runInSandbox.mockResolvedValue({ exitCode: 0, stdout: '', stderr: '' });
const readFileViaSandbox: ReadFileViaSandboxMock = jest.fn<
Promise<string | null>,
[SandboxWorkspace, string]
>();
readFileViaSandbox.mockResolvedValue(null);
const setupSandboxWorkspace = loadSetupSandboxWorkspaceWithFsMocks(
runInSandbox,
readFileViaSandbox,
);
const writeFile = jest
.fn<Promise<void>, [string, string | Buffer, { recursive?: boolean }?]>()
.mockImplementation(async (path) => {
await Promise.resolve();
if (path === '/sandbox/.sandbox-initialized') {
throw new Error('primary write failed');
}
});
await expect(
setupSandboxWorkspace(createLocalWorkspace(writeFile), createSetupContext()),
).resolves.toBe(true);
expect(
runInSandbox.mock.calls.some(([, command]) => command.includes('.sandbox-initialized')),
).toBe(true);
});
it('includes the failing setup step when marker fallback fails', async () => {
const runInSandbox: RunInSandboxMock = jest.fn<
Promise<{ exitCode: number; stdout: string; stderr: string }>,
[SandboxWorkspace, string, string?]
>();
runInSandbox.mockImplementation(async (_workspace, command) => {
await Promise.resolve();
return command.includes('.sandbox-initialized')
? { exitCode: 1, stdout: '', stderr: 'fallback failed' }
: { exitCode: 0, stdout: '', stderr: '' };
});
const readFileViaSandbox: ReadFileViaSandboxMock = jest.fn<
Promise<string | null>,
[SandboxWorkspace, string]
>();
readFileViaSandbox.mockResolvedValue(null);
const setupSandboxWorkspace = loadSetupSandboxWorkspaceWithFsMocks(
runInSandbox,
readFileViaSandbox,
);
const writeFile = jest
.fn<Promise<void>, [string, string | Buffer, { recursive?: boolean }?]>()
.mockImplementation(async (path) => {
await Promise.resolve();
if (path === '/sandbox/.sandbox-initialized') {
throw new Error('primary write failed');
}
});
const error = await setupSandboxWorkspace(
createLocalWorkspace(writeFile),
createSetupContext(),
).catch((caught: unknown) => caught);
expect(error).toBeInstanceOf(Error);
expect((error as Error).message).toContain(
'Sandbox workspace setup failed during write-initialization-marker',
);
expect((error as Error).message).toContain(
'Failed to write sandbox workspace file "/sandbox/.sandbox-initialized"',
);
expect((error as Error).message).toContain('primary write failed');
expect((error as Error).message).toContain('command fallback failed');
});
it('retries packing the workspace SDK after a null pack result', async () => {
const originalLinkSdk = process.env.N8N_INSTANCE_AI_SANDBOX_LINK_SDK;
process.env.N8N_INSTANCE_AI_SANDBOX_LINK_SDK = '1';
const tarball = Buffer.from('sdk');
const packWorkspaceSdk = jest.fn().mockResolvedValueOnce(null).mockResolvedValueOnce({
filename: 'workflow-sdk.tgz',
tarball,
version: '1.0.0',
sdkPath: '/host/sdk',
});
const runInSandbox: RunInSandboxMock = jest.fn<
Promise<{ exitCode: number; stdout: string; stderr: string }>,
[SandboxWorkspace, string, string?]
>();
runInSandbox.mockResolvedValue({ exitCode: 0, stdout: '', stderr: '' });
const linkWorkspaceSdkIfEnabled = loadLinkWorkspaceSdkWithMocks(packWorkspaceSdk, runInSandbox);
const writeFile = jest.fn<Promise<void>, [string, Buffer, { recursive?: boolean }?]>(
async () => {},
);
const workspace = {
filesystem: {
provider: 'daytona',
writeFile,
},
} as unknown as SandboxWorkspace;
try {
await expect(linkWorkspaceSdkIfEnabled(workspace, '/workspace')).rejects.toThrow(
'workspace SDK could not be packed',
);
await linkWorkspaceSdkIfEnabled(workspace, '/workspace');
} finally {
if (originalLinkSdk === undefined) {
delete process.env.N8N_INSTANCE_AI_SANDBOX_LINK_SDK;
} else {
process.env.N8N_INSTANCE_AI_SANDBOX_LINK_SDK = originalLinkSdk;
}
}
expect(packWorkspaceSdk).toHaveBeenCalledTimes(2);
expect(writeFile).toHaveBeenCalledWith('/workspace/workflow-sdk.tgz', tarball, {
recursive: true,
});
});
});
describe('getWorkspaceRoot', () => {
it('uses the resolved filesystem base path for lazy local workspaces', async () => {
let initialized = false;
const executeCommand = jest.fn();
const init = jest.fn<Promise<void>, []>(async () => {
await Promise.resolve();
initialized = true;
});
const workspace = {
filesystem: {
provider: 'lazy',
get basePath() {
return initialized ? '/sandbox' : undefined;
},
init,
writeFile: jest.fn(),
mkdir: jest.fn(),
},
sandbox: {
executeCommand,
},
} as unknown as SandboxWorkspace;
await expect(getWorkspaceRoot(workspace)).resolves.toBe('/sandbox');
expect(init).toHaveBeenCalledTimes(1);
expect(executeCommand).not.toHaveBeenCalled();
});
});
describe('formatNodeCatalogLine', () => {

View File

@ -21,6 +21,8 @@ interface DisabledSandboxConfig extends SandboxConfigBase {
interface DaytonaSandboxConfig extends SandboxConfigBase {
enabled: true;
provider: 'daytona';
id?: string;
name?: string;
daytonaApiUrl?: string;
daytonaApiKey?: string;
image?: string;
@ -73,6 +75,8 @@ export async function createSandbox(
// In proxy mode, resolve a fresh token via getAuthToken; in direct mode use the static key.
const apiKey = config.getAuthToken ? await config.getAuthToken() : config.daytonaApiKey;
return new DaytonaSandbox({
id: config.id,
name: config.name,
apiKey,
apiUrl: config.daytonaApiUrl,
...(config.image ? { image: config.image } : {}),

View File

@ -0,0 +1,317 @@
import {
BaseFilesystem,
BaseSandbox,
Workspace,
type CommandResult,
type CopyOptions,
type ExecuteCommandOptions,
type FileContent,
type FileEntry,
type FileStat,
type ListOptions,
type ProviderStatus,
type ReadOptions,
type RemoveOptions,
type WorkspaceFilesystem,
type WorkspaceSandbox,
type WriteOptions,
} from '@n8n/agents';
export type RuntimeWorkspaceResolver = () => Promise<Workspace | undefined>;
export interface LazyRuntimeWorkspaceOptions {
ensureWorkspace: RuntimeWorkspaceResolver;
id?: string;
name?: string;
}
type WorkspaceResolvedListener = (workspace: Workspace) => void;
type WorkspaceDestroyedListener = () => void;
export function createLazyRuntimeWorkspace({
ensureWorkspace,
id = 'instance-ai-runtime-workspace',
name = 'Instance AI runtime workspace',
}: LazyRuntimeWorkspaceOptions): Workspace {
const resolver = new LazyRuntimeWorkspaceResolver(ensureWorkspace);
return new Workspace({
id,
name,
filesystem: new LazyRuntimeFilesystem(resolver),
sandbox: new LazyRuntimeSandbox(resolver),
});
}
class LazyRuntimeWorkspaceResolver {
private workspacePromise: Promise<Workspace | undefined> | undefined;
private resolvedWorkspace: Workspace | undefined;
private workspaceDestroyPromise: Promise<void> | undefined;
private readonly resolvedListeners = new Set<WorkspaceResolvedListener>();
private readonly destroyedListeners = new Set<WorkspaceDestroyedListener>();
constructor(private readonly ensureWorkspace: RuntimeWorkspaceResolver) {}
get current(): Workspace | undefined {
return this.resolvedWorkspace;
}
onResolved(listener: WorkspaceResolvedListener): void {
this.resolvedListeners.add(listener);
if (this.resolvedWorkspace) listener(this.resolvedWorkspace);
}
onDestroyed(listener: WorkspaceDestroyedListener): void {
this.destroyedListeners.add(listener);
}
async getWorkspace(): Promise<Workspace> {
this.workspacePromise ??= this.ensureWorkspace()
.then((workspace) => {
this.setResolvedWorkspace(workspace);
return workspace;
})
.catch((error: unknown) => {
this.workspacePromise = undefined;
throw error;
});
const workspace = await this.workspacePromise;
if (!workspace) {
this.workspacePromise = undefined;
throw new Error('Instance AI runtime workspace is unavailable.');
}
return workspace;
}
async destroyResolvedWorkspace(): Promise<void> {
if (this.workspaceDestroyPromise) return await this.workspaceDestroyPromise;
const workspace =
this.resolvedWorkspace ?? (await this.workspacePromise?.catch(() => undefined));
if (!workspace) {
this.workspacePromise = undefined;
return;
}
this.workspaceDestroyPromise = workspace.destroy().then(() => {
this.workspacePromise = undefined;
this.resolvedWorkspace = undefined;
this.notifyDestroyed();
});
try {
await this.workspaceDestroyPromise;
} finally {
this.workspaceDestroyPromise = undefined;
}
}
async getFilesystem(): Promise<WorkspaceFilesystem> {
const filesystem = (await this.getWorkspace()).filesystem;
if (!filesystem) {
throw new Error('Instance AI runtime workspace has no filesystem.');
}
return filesystem;
}
async getSandbox(): Promise<WorkspaceSandbox> {
const sandbox = (await this.getWorkspace()).sandbox;
if (!sandbox) {
throw new Error('Instance AI runtime workspace has no sandbox.');
}
return sandbox;
}
private setResolvedWorkspace(workspace: Workspace | undefined): void {
this.resolvedWorkspace = workspace;
if (!workspace) return;
for (const listener of this.resolvedListeners) {
listener(workspace);
}
}
private notifyDestroyed(): void {
for (const listener of this.destroyedListeners) {
listener();
}
}
}
class LazyRuntimeFilesystem extends BaseFilesystem {
readonly id = 'instance-ai-runtime-filesystem';
readonly name = 'InstanceAiRuntimeFilesystem';
readonly provider = 'lazy';
status: ProviderStatus = 'pending';
constructor(private readonly resolver: LazyRuntimeWorkspaceResolver) {
super();
this.resolver.onResolved((workspace) => {
this.status = workspace.filesystem?.status ?? this.status;
});
this.resolver.onDestroyed(() => {
this.status = 'destroyed';
});
}
get readOnly(): boolean | undefined {
return this.resolver.current?.filesystem?.readOnly;
}
get basePath(): string | undefined {
return this.resolver.current?.filesystem?.basePath;
}
override async init(): Promise<void> {
const filesystem = await this.getFilesystem();
await (filesystem._init?.() ?? filesystem.init?.());
this.syncStatus(filesystem);
}
override async destroy(): Promise<void> {
await this.resolver.destroyResolvedWorkspace();
}
getInstructions(): string {
const instructions = this.resolver.current?.filesystem?.getInstructions?.();
if (instructions) return instructions;
return [
'Workspace file tools are available and create the runtime workspace on first use.',
'Use relative workspace paths unless a loaded skill explicitly provides an absolute workspace path.',
].join(' ');
}
async readFile(path: string, options?: ReadOptions): Promise<string | Buffer> {
return await (await this.getFilesystem()).readFile(path, options);
}
async writeFile(path: string, content: FileContent, options?: WriteOptions): Promise<void> {
await (await this.getFilesystem()).writeFile(path, content, options);
}
async appendFile(path: string, content: FileContent): Promise<void> {
await (await this.getFilesystem()).appendFile(path, content);
}
async deleteFile(path: string, options?: RemoveOptions): Promise<void> {
await (await this.getFilesystem()).deleteFile(path, options);
}
async copyFile(src: string, dest: string, options?: CopyOptions): Promise<void> {
await (await this.getFilesystem()).copyFile(src, dest, options);
}
async moveFile(src: string, dest: string, options?: CopyOptions): Promise<void> {
await (await this.getFilesystem()).moveFile(src, dest, options);
}
async mkdir(path: string, options?: { recursive?: boolean }): Promise<void> {
await (await this.getFilesystem()).mkdir(path, options);
}
async rmdir(path: string, options?: RemoveOptions): Promise<void> {
await (await this.getFilesystem()).rmdir(path, options);
}
async readdir(path: string, options?: ListOptions): Promise<FileEntry[]> {
return await (await this.getFilesystem()).readdir(path, options);
}
async exists(path: string): Promise<boolean> {
return await (await this.getFilesystem()).exists(path);
}
async stat(path: string): Promise<FileStat> {
return await (await this.getFilesystem()).stat(path);
}
private async getFilesystem(): Promise<WorkspaceFilesystem> {
const filesystem = await this.resolver.getFilesystem();
this.syncStatus(filesystem);
return filesystem;
}
private syncStatus(filesystem: WorkspaceFilesystem): void {
this.status = filesystem.status;
}
}
class LazyRuntimeSandbox extends BaseSandbox {
readonly id = 'instance-ai-runtime-sandbox';
readonly name = 'InstanceAiRuntimeSandbox';
readonly provider = 'lazy';
status: ProviderStatus = 'pending';
constructor(private readonly resolver: LazyRuntimeWorkspaceResolver) {
super();
this.resolver.onResolved((workspace) => {
this.status = workspace.sandbox?.status ?? this.status;
});
this.resolver.onDestroyed(() => {
this.status = 'destroyed';
});
}
override async start(): Promise<void> {
const sandbox = await this.getSandbox();
await (sandbox._start?.() ?? sandbox.start?.());
this.syncStatus(sandbox);
}
override async stop(): Promise<void> {
const sandbox = this.resolver.current?.sandbox;
if (!sandbox) return;
await (sandbox._stop?.() ?? sandbox.stop?.());
this.syncStatus(sandbox);
}
override async destroy(): Promise<void> {
await this.resolver.destroyResolvedWorkspace();
}
getDefaultCommandEnv(): NodeJS.ProcessEnv {
return this.resolver.current?.sandbox?.getDefaultCommandEnv?.() ?? {};
}
override async executeCommand(
command: string,
args: string[] = [],
options?: ExecuteCommandOptions,
): Promise<CommandResult> {
const sandbox = await this.getSandbox();
if (!sandbox.executeCommand) {
throw new Error('Instance AI runtime sandbox does not support command execution.');
}
const defaultEnv = sandbox.getDefaultCommandEnv?.();
try {
return await sandbox.executeCommand(command, args, {
...options,
...(defaultEnv ? { env: { ...defaultEnv, ...options?.env } } : {}),
});
} finally {
this.syncStatus(sandbox);
}
}
override getInstructions(): string {
const instructions = this.resolver.current?.sandbox?.getInstructions?.();
if (instructions) return instructions;
return 'Workspace command tools are available and create the runtime sandbox on first use.';
}
private async getSandbox(): Promise<WorkspaceSandbox> {
const sandbox = await this.resolver.getSandbox();
this.syncStatus(sandbox);
return sandbox;
}
private syncStatus(sandbox: WorkspaceSandbox): void {
this.status = sandbox.status;
}
}

View File

@ -19,10 +19,16 @@ export interface SandboxWorkspace {
filesystem?: {
provider?: string;
basePath?: string;
writeFile: (path: string, content: string, options?: { recursive?: boolean }) => Promise<void>;
init?: () => Promise<void>;
writeFile: (
path: string,
content: string | Buffer,
options?: { recursive?: boolean },
) => Promise<void>;
mkdir: (path: string, options?: { recursive?: boolean }) => Promise<void>;
};
sandbox?: {
provider?: string;
executeCommand?: (
command: string,
args?: string[],

View File

@ -26,7 +26,11 @@ import { createRequire } from 'node:module';
import type { Logger } from '../logger';
import type { InstanceAiContext, SearchableNodeDescription } from '../types';
import { isLinkWorkspaceSdkEnabled } from './pack-workspace-sdk';
import {
isLinkWorkspaceSdkEnabled,
packWorkspaceSdk,
type WorkspaceSdkTarball,
} from './pack-workspace-sdk';
import {
runInSandbox,
readFileViaSandbox,
@ -36,6 +40,44 @@ import {
} from './sandbox-fs';
const hostRequire = createRequire(__filename);
const NOOP_LOGGER: Logger = {
info: () => {},
warn: () => {},
error: () => {},
debug: () => {},
};
type SandboxWorkspaceSetupStep =
| 'resolve-workspace-root'
| 'read-initialization-marker'
| 'list-node-types'
| 'write-workspace-files'
| 'write-curated-examples'
| 'install-dependencies'
| 'link-workspace-sdk'
| 'write-initialization-marker';
function getErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
export class SandboxWorkspaceSetupError extends Error {
constructor(
readonly step: SandboxWorkspaceSetupStep,
readonly originalError: unknown,
) {
super(`Sandbox workspace setup failed during ${step}: ${getErrorMessage(originalError)}`);
this.name = 'SandboxWorkspaceSetupError';
}
}
async function setupStep<T>(step: SandboxWorkspaceSetupStep, action: () => Promise<T>): Promise<T> {
try {
return await action();
} catch (error) {
throw new SandboxWorkspaceSetupError(step, error);
}
}
export const WORKSPACE_DIR = 'workspace';
@ -99,6 +141,35 @@ const SANDBOX_TSX_VERSION = resolveHostDepVersion('tsx');
*/
const SANDBOX_TYPES_NODE_VERSION = '24.10.1';
function assertSafeWorkspaceRelativePath(path: string): void {
const segments = path.split('/');
if (
path.length === 0 ||
path.startsWith('/') ||
path.includes('\\') ||
path.includes('\0') ||
segments.some((segment) => segment === '..')
) {
throw new Error(`Sandbox workspace path must stay within the workspace root: ${path}`);
}
}
function joinWorkspacePath(root: string, path: string): string {
assertSafeWorkspaceRelativePath(path);
const normalizedRoot = root.replace(/\/+$/, '') || '/';
const normalizedPath = path
.split('/')
.filter((segment) => segment.length > 0 && segment !== '.')
.join('/');
if (normalizedPath.length === 0) {
throw new Error(`Sandbox workspace path must stay within the workspace root: ${path}`);
}
return normalizedRoot === '/' ? `/${normalizedPath}` : `${normalizedRoot}/${normalizedPath}`;
}
function buildPackageJson(sdkSpecifier: string | null): string {
const dependencies: Record<string, string> = {
tsx: SANDBOX_TSX_VERSION,
@ -163,6 +234,61 @@ function buildLocalProviderPackageJson(): string {
return buildPackageJson(`file:${sdkPath}`);
}
function getSandboxProvider(workspace: SandboxWorkspace): string | undefined {
return workspace.filesystem?.provider ?? workspace.sandbox?.provider;
}
function buildWorkspacePackageJson(workspace: SandboxWorkspace): string {
return getSandboxProvider(workspace) === 'local' ? buildLocalProviderPackageJson() : PACKAGE_JSON;
}
let sdkTarballPromise: Promise<WorkspaceSdkTarball | null> | null = null;
export async function linkWorkspaceSdkIfEnabled(
workspace: SandboxWorkspace,
root: string,
logger?: Logger,
): Promise<void> {
if (!isLinkWorkspaceSdkEnabled() || getSandboxProvider(workspace) === 'local') return;
sdkTarballPromise ??= packWorkspaceSdk(logger ?? NOOP_LOGGER).catch((error: unknown) => {
sdkTarballPromise = null;
throw error;
});
const packed = await sdkTarballPromise;
if (!packed) {
sdkTarballPromise = null;
throw new Error(
'N8N_INSTANCE_AI_SANDBOX_LINK_SDK is enabled, but the workspace SDK could not be packed. Run `pnpm build` in packages/@n8n/workflow-sdk or unset N8N_INSTANCE_AI_SANDBOX_LINK_SDK.',
);
}
const remotePath = joinWorkspacePath(root, packed.filename);
if (workspace.filesystem) {
await writeWorkspaceFile(workspace, workspace.filesystem, remotePath, packed.tarball);
} else {
await writeFileViaSandbox(workspace, remotePath, packed.tarball);
}
const install = await runInSandbox(
workspace,
`npm install '${escapeSingleQuotes(remotePath)}' --no-save --ignore-scripts --force`,
root,
);
if (install.exitCode !== 0) {
logger?.error('Failed to link workspace SDK into sandbox', {
exitCode: install.exitCode,
stderr: install.stderr,
});
throw new Error(`Failed to install workspace SDK tarball: ${install.stderr}`);
}
logger?.info('Linked workspace SDK into sandbox', {
version: packed.version,
sdkPath: packed.sdkPath,
});
}
/**
* Runner script that executes a workflow TS file via tsx, calls validate() + toJSON(),
* and outputs structured JSON to stdout. Executed via: node --import tsx build.mjs ./src/workflow.ts
@ -241,19 +367,21 @@ async function writeWorkspaceFiles(
// `writeFile` only creates parent dirs as a side-effect of writing a file.
await Promise.all(
ALWAYS_PRESENT_DIRS.map(
async (dir) => await filesystem.mkdir(`${root}/${dir}`, { recursive: true }),
async (dir) =>
await createWorkspaceDirectory(workspace, filesystem, joinWorkspacePath(root, dir)),
),
);
await Promise.all(
[...files].map(async ([path, content]) => {
await filesystem.writeFile(`${root}/${path}`, content, { recursive: true });
}),
[...files].map(
async ([path, content]) =>
await writeWorkspaceFile(workspace, filesystem, joinWorkspacePath(root, path), content),
),
);
return;
}
const dirList = ALWAYS_PRESENT_DIRS.map(
(dir) => `'${escapeSingleQuotes(`${root}/${dir}`)}'`,
(dir) => `'${escapeSingleQuotes(joinWorkspacePath(root, dir))}'`,
).join(' ');
const result = await runInSandbox(workspace, `mkdir -p ${dirList}`);
if (result.exitCode !== 0) {
@ -261,7 +389,49 @@ async function writeWorkspaceFiles(
}
for (const [path, content] of files) {
await writeFileViaSandbox(workspace, `${root}/${path}`, content);
await writeFileViaSandbox(workspace, joinWorkspacePath(root, path), content);
}
}
type WorkspaceFilesystem = NonNullable<SandboxWorkspace['filesystem']>;
async function createWorkspaceDirectory(
workspace: SandboxWorkspace,
filesystem: WorkspaceFilesystem,
path: string,
): Promise<void> {
try {
await filesystem.mkdir(path, { recursive: true });
} catch (error) {
try {
const result = await runInSandbox(workspace, `mkdir -p '${escapeSingleQuotes(path)}'`);
if (result.exitCode === 0) return;
throw new Error(result.stderr || `mkdir exited with code ${result.exitCode}`);
} catch (fallbackError) {
throw new Error(
`Failed to create sandbox workspace directory "${path}": ${getErrorMessage(error)}; command fallback failed: ${getErrorMessage(fallbackError)}`,
);
}
}
}
async function writeWorkspaceFile(
workspace: SandboxWorkspace,
filesystem: WorkspaceFilesystem,
path: string,
content: string | Buffer,
): Promise<void> {
try {
await filesystem.writeFile(path, content, { recursive: true });
} catch (error) {
try {
await writeFileViaSandbox(workspace, path, content);
} catch (fallbackError) {
throw new Error(
`Failed to write sandbox workspace file "${path}": ${getErrorMessage(error)}; command fallback failed: ${getErrorMessage(fallbackError)}`,
);
}
}
}
@ -273,12 +443,22 @@ const workspaceRootCache = new WeakMap<SandboxWorkspace, string>();
function getLocalFilesystemRoot(workspace: SandboxWorkspace): string | null {
const filesystem = workspace.filesystem;
if (!filesystem || filesystem.provider !== 'local') return null;
if (!filesystem) return null;
const provider = filesystem.provider;
if (provider !== 'local' && provider !== 'lazy') return null;
const basePath = Reflect.get(filesystem, 'basePath');
return typeof basePath === 'string' && basePath.length > 0 ? basePath : null;
}
async function initializeLazyFilesystem(workspace: SandboxWorkspace): Promise<void> {
const filesystem = workspace.filesystem;
if (filesystem?.provider !== 'lazy') return;
await filesystem.init?.();
}
export async function getWorkspaceRoot(workspace: SandboxWorkspace): Promise<string> {
const cached = workspaceRootCache.get(workspace);
if (cached) return cached;
@ -289,6 +469,13 @@ export async function getWorkspaceRoot(workspace: SandboxWorkspace): Promise<str
return localRoot;
}
await initializeLazyFilesystem(workspace);
const initializedLocalRoot = getLocalFilesystemRoot(workspace);
if (initializedLocalRoot) {
workspaceRootCache.set(workspace, initializedLocalRoot);
return initializedLocalRoot;
}
const result = await runInSandbox(workspace, 'echo $HOME');
const home = result.stdout.trim() || '/home/daytona';
const root = `${home}/${WORKSPACE_DIR}`;
@ -350,11 +537,17 @@ export async function setupSandboxWorkspace(
workspace: SandboxWorkspace,
context: InstanceAiContext,
): Promise<boolean> {
const root = await getWorkspaceRoot(workspace);
const markerFile = `${root}/.sandbox-initialized`;
const root = await setupStep(
'resolve-workspace-root',
async () => await getWorkspaceRoot(workspace),
);
const markerFile = joinWorkspacePath(root, '.sandbox-initialized');
// Check marker file for idempotency
const marker = await readFileViaSandbox(workspace, markerFile);
const marker = await setupStep(
'read-initialization-marker',
async () => await readFileViaSandbox(workspace, markerFile),
);
if (marker !== null) return false;
// ── Collect all files ──────────────────────────────────────────────────
@ -365,12 +558,15 @@ export async function setupSandboxWorkspace(
// its workspace location via `file:` — this makes SDK changes visible in
// the sandbox after `pnpm build`, without a publish. Daytona/n8n-sandbox
// stay on the registry-pinned PACKAGE_JSON (they can't see the host FS).
files.set('package.json', buildLocalProviderPackageJson());
files.set('package.json', buildWorkspacePackageJson(workspace));
files.set('tsconfig.json', TSCONFIG_JSON);
files.set('build.mjs', BUILD_MJS);
// Node types catalog
const nodeTypes = await context.nodeService.listSearchable();
const nodeTypes = await setupStep(
'list-node-types',
async () => await context.nodeService.listSearchable(),
);
const catalogLines = nodeTypes.map(formatNodeCatalogLine);
files.set('node-types/index.txt', catalogLines.join('\n'));
@ -394,19 +590,36 @@ export async function setupSandboxWorkspace(
// ── Write workspace files ──────────────────────────────────────────────
await writeWorkspaceFiles(workspace, root, files);
await writeCuratedExamples(workspace, context.logger);
await setupStep(
'write-workspace-files',
async () => await writeWorkspaceFiles(workspace, root, files),
);
await setupStep(
'write-curated-examples',
async () => await writeCuratedExamples(workspace, context.logger),
);
// npm install (must run after package.json is in place)
const npmResult = await runInSandbox(workspace, 'npm install --ignore-scripts', root);
if (npmResult.exitCode !== 0) {
throw new Error(`Sandbox npm install failed: ${npmResult.stderr}`);
}
await setupStep('install-dependencies', async () => {
const npmResult = await runInSandbox(workspace, 'npm install --ignore-scripts', root);
if (npmResult.exitCode !== 0) {
throw new Error(`Sandbox npm install failed: ${npmResult.stderr}`);
}
});
await writeWorkspaceFiles(
workspace,
root,
new Map([['.sandbox-initialized', new Date().toISOString()]]),
await setupStep(
'link-workspace-sdk',
async () => await linkWorkspaceSdkIfEnabled(workspace, root, context.logger),
);
await setupStep(
'write-initialization-marker',
async () =>
await writeWorkspaceFiles(
workspace,
root,
new Map([['.sandbox-initialized', new Date().toISOString()]]),
),
);
return true;

View File

@ -10,10 +10,10 @@ jest.mock('@n8n/instance-ai', () => {
disconnect = jest.fn();
},
createDomainAccessTracker: jest.fn(),
BuilderSandboxFactory: class {},
SnapshotManager: class {},
createSandbox: jest.fn(),
createWorkspace: jest.fn(),
createLazyRuntimeWorkspace: jest.fn(),
setupSandboxWorkspace: jest.fn(),
workflowBuildOutcomeSchema: z.object({}),
handleBuildOutcome: jest.fn(),
handleVerificationVerdict: jest.fn(),

View File

@ -10,10 +10,13 @@ jest.mock('@n8n/instance-ai', () => {
disconnect = jest.fn();
},
createDomainAccessTracker: jest.fn(),
BuilderSandboxFactory: class {},
SnapshotManager: class {},
createSandbox: jest.fn(),
createWorkspace: jest.fn(),
createLazyRuntimeWorkspace: jest.fn((args: { ensureWorkspace: () => Promise<unknown> }) => ({
id: 'lazy-runtime-workspace',
ensureWorkspace: args.ensureWorkspace,
})),
setupSandboxWorkspace: jest.fn(),
workflowBuildOutcomeSchema: z.object({}),
handleBuildOutcome: jest.fn(),
handleVerificationVerdict: jest.fn(),
@ -33,6 +36,11 @@ jest.mock('@n8n/instance-ai', () => {
),
createInstanceAgent: jest.fn(),
createAllTools: jest.fn(),
WorkflowTaskCoordinator: class {},
WorkflowLoopStorage: class {},
ThreadTaskStorage: class {},
PlannedTaskStorage: class {},
PlannedTaskCoordinator: class {},
InstanceAiTerminalResponseGuard: class {
constructor(private readonly options: { runId: string; rootAgentId: string }) {}
@ -114,7 +122,14 @@ jest.mock('@n8n/instance-ai', () => {
import type { User } from '@n8n/db';
import type { InstanceAiAgentNode, InstanceAiEvent } from '@n8n/api-types';
import {
createAllTools,
createLazyRuntimeWorkspace,
createSandbox,
createWorkspace,
resumeAgentRun,
setupSandboxWorkspace,
type InstanceAiContext,
type SandboxConfig,
type ManagedBackgroundTask,
type InstanceAiTraceContext,
type SpawnBackgroundTaskOptions,
@ -461,6 +476,58 @@ function createTemporaryCleanupService({
}
const fakeUser = { id: 'user-1' } as User;
const daytonaSandboxConfig = {
enabled: true,
provider: 'daytona',
} satisfies SandboxConfig;
type WorkspaceServiceInternals = {
sandboxes: Map<string, unknown>;
sandboxCreations: Map<string, Promise<unknown>>;
resolveSandboxConfig: jest.MockedFunction<(user: User) => Promise<SandboxConfig>>;
getOrCreateWorkspace: (
threadId: string,
user: User,
context: InstanceAiContext,
) => Promise<unknown>;
};
type ShutdownServiceInternals = {
shutdown: () => Promise<void>;
stopCheckpointPruning: jest.MockedFunction<() => void>;
liveness: { shutdown: jest.MockedFunction<() => void> };
runState: {
shutdown: jest.MockedFunction<
() => {
activeRuns: [];
suspendedRuns: [];
}
>;
};
backgroundTasks: { cancelAll: jest.MockedFunction<() => ManagedBackgroundTask[]> };
traceContextsByRunId: Map<string, { threadId: string }>;
finalizeRunTracing: jest.MockedFunction<
(runId: string, tracing: InstanceAiTraceContext | undefined, options: unknown) => Promise<void>
>;
finalizeBackgroundTaskTracing: jest.MockedFunction<
(task: ManagedBackgroundTask, status: 'cancelled') => Promise<void>
>;
finalizeRemainingMessageTraceRoots: jest.MockedFunction<
(threadId: string, options: unknown) => Promise<void>
>;
gatewayRegistry: { disconnectAll: jest.MockedFunction<() => void> };
sandboxes: Map<
string,
{
sandbox: unknown;
workspace: { destroy: jest.MockedFunction<() => Promise<void>> };
}
>;
domainAccessTrackersByThread: Map<string, unknown>;
eventBus: { clear: jest.MockedFunction<() => void> };
_mcpClientManager?: { disconnect: jest.MockedFunction<() => Promise<void>> };
logger: { debug: jest.Mock; warn: jest.Mock };
};
type TerminalOutcomeServiceInternals = {
replayUndeliveredTerminalOutcomes: (
@ -685,6 +752,281 @@ function makeAgentTree(): InstanceAiAgentNode {
};
}
describe('InstanceAiService — runtime workspace setup', () => {
beforeEach(() => {
jest.clearAllMocks();
(createSandbox as jest.Mock).mockReset();
(createWorkspace as jest.Mock).mockReset();
(setupSandboxWorkspace as jest.Mock).mockReset();
(createAllTools as jest.Mock).mockReset();
(createLazyRuntimeWorkspace as jest.Mock).mockImplementation(
(args: { ensureWorkspace: () => Promise<unknown> }) => ({
id: 'lazy-runtime-workspace',
ensureWorkspace: args.ensureWorkspace,
}),
);
});
it('serializes workspace creation for concurrent calls on the same thread', async () => {
const service = Object.create(
InstanceAiService.prototype,
) as unknown as WorkspaceServiceInternals;
service.sandboxes = new Map();
service.sandboxCreations = new Map();
service.resolveSandboxConfig = jest.fn(async (_user: User) => daytonaSandboxConfig);
let resolveSandbox!: (sandbox: unknown) => void;
const sandboxPromise = new Promise((resolve) => {
resolveSandbox = resolve;
});
const sandbox = { id: 'sandbox-1' };
const workspace = {
init: jest.fn(async () => {}),
destroy: jest.fn(async () => {}),
};
(createSandbox as jest.Mock).mockReturnValue(sandboxPromise);
(createWorkspace as jest.Mock).mockReturnValue(workspace);
(setupSandboxWorkspace as jest.Mock).mockResolvedValue(undefined);
const first = service.getOrCreateWorkspace('thread-1', fakeUser, {} as InstanceAiContext);
const second = service.getOrCreateWorkspace('thread-1', fakeUser, {} as InstanceAiContext);
resolveSandbox(sandbox);
const [firstEntry, secondEntry] = await Promise.all([first, second]);
expect(firstEntry).toBe(secondEntry);
expect(createSandbox).toHaveBeenCalledTimes(1);
expect(createSandbox).toHaveBeenCalledWith(
expect.objectContaining({
id: 'instance-ai-thread-thread-1',
name: 'instance-ai-thread-thread-1',
}),
);
expect(createWorkspace).toHaveBeenCalledTimes(1);
expect(workspace.init).toHaveBeenCalledTimes(1);
expect(setupSandboxWorkspace).toHaveBeenCalledTimes(1);
expect(service.sandboxCreations.size).toBe(0);
});
it('keeps the sandbox after setup failure and retries setup on the next use', async () => {
const service = Object.create(
InstanceAiService.prototype,
) as unknown as WorkspaceServiceInternals;
service.sandboxes = new Map();
service.sandboxCreations = new Map();
service.resolveSandboxConfig = jest.fn(async (_user: User) => daytonaSandboxConfig);
const sandbox = { id: 'sandbox-1' };
const workspace = {
init: jest.fn(async () => {}),
destroy: jest.fn(async () => {}),
};
(createSandbox as jest.Mock).mockResolvedValue(sandbox);
(createWorkspace as jest.Mock).mockReturnValue(workspace);
(setupSandboxWorkspace as jest.Mock)
.mockRejectedValueOnce(new Error('setup failed'))
.mockResolvedValueOnce(undefined);
await expect(
service.getOrCreateWorkspace('thread-1', fakeUser, {} as InstanceAiContext),
).rejects.toThrow('setup failed');
expect(service.sandboxes.has('thread-1')).toBe(true);
expect(workspace.destroy).not.toHaveBeenCalled();
const entry = await service.getOrCreateWorkspace('thread-1', fakeUser, {} as InstanceAiContext);
expect(entry).toBe(service.sandboxes.get('thread-1'));
expect(createSandbox).toHaveBeenCalledTimes(1);
expect(setupSandboxWorkspace).toHaveBeenCalledTimes(2);
});
it('destroys the workspace when sandbox startup fails', async () => {
const service = Object.create(
InstanceAiService.prototype,
) as unknown as WorkspaceServiceInternals;
service.sandboxes = new Map();
service.sandboxCreations = new Map();
service.resolveSandboxConfig = jest.fn(async (_user: User) => daytonaSandboxConfig);
const sandbox = { id: 'sandbox-1' };
const workspace = {
init: jest.fn(async () => {
throw new Error('init failed');
}),
destroy: jest.fn(async () => {}),
};
(createSandbox as jest.Mock).mockResolvedValue(sandbox);
(createWorkspace as jest.Mock).mockReturnValue(workspace);
await expect(
service.getOrCreateWorkspace('thread-1', fakeUser, {} as InstanceAiContext),
).rejects.toThrow('init failed');
expect(workspace.destroy).toHaveBeenCalledTimes(1);
expect(service.sandboxes.has('thread-1')).toBe(false);
expect(setupSandboxWorkspace).not.toHaveBeenCalled();
});
it('defers sandbox creation and setup until the lazy workspace is used', async () => {
const service = Object.create(InstanceAiService.prototype) as unknown as {
createExecutionEnvironment: (
user: User,
threadId: string,
runId: string,
abortSignal: AbortSignal,
) => Promise<{ orchestrationContext: { workspace?: unknown } }>;
settingsService: {
getAdminSettings: jest.Mock;
isLocalGatewayDisabledForUser: jest.Mock;
getPermissions: jest.Mock;
};
gatewayRegistry: { findGateway: jest.Mock };
aiService: { isProxyEnabled: jest.Mock };
adapterService: {
createContext: jest.Mock;
getNodeDefinitionDirs: jest.Mock;
};
sourceControlPreferencesService: { getPreferences: jest.Mock };
resolveAgentModelConfig: jest.Mock;
ensureThreadExists: jest.Mock;
agentMemory: unknown;
dbIterationLogStorage: unknown;
dbSnapshotStorage: unknown;
checkpointStore: unknown;
instanceAiConfig: { subAgentMaxSteps: number; browserMcp: boolean };
defaultTimeZone: string;
eventBus: unknown;
logger: unknown;
telemetry: { track: jest.Mock };
oauth2CallbackUrl: string;
webhookBaseUrl: string;
formBaseUrl: string;
runState: { touchActiveRun: jest.Mock; registerPendingConfirmation: jest.Mock };
spawnBackgroundTask: jest.Mock;
cancelBackgroundTask: jest.Mock;
backgroundTasks: { touchTask: jest.Mock };
schedulePlannedTasks: jest.Mock;
sendCorrectionToTask: jest.Mock;
sandboxes: Map<string, unknown>;
sandboxCreations: Map<string, Promise<unknown>>;
domainAccessTrackersByThread: Map<string, unknown>;
resolveSandboxConfig: jest.Mock;
};
service.settingsService = {
getAdminSettings: jest.fn(() => ({ localGatewayDisabled: false, sandboxEnabled: true })),
isLocalGatewayDisabledForUser: jest.fn(async () => false),
getPermissions: jest.fn(() => ({})),
};
service.gatewayRegistry = { findGateway: jest.fn(() => undefined) };
service.aiService = { isProxyEnabled: jest.fn(() => false) };
service.adapterService = {
createContext: jest.fn(() => ({})),
getNodeDefinitionDirs: jest.fn(() => []),
};
service.sourceControlPreferencesService = {
getPreferences: jest.fn(() => ({ branchReadOnly: false })),
};
service.resolveAgentModelConfig = jest.fn(async () => 'model-1');
service.ensureThreadExists = jest.fn(async () => {});
service.agentMemory = {};
service.dbIterationLogStorage = {};
service.dbSnapshotStorage = {};
service.checkpointStore = {};
service.instanceAiConfig = { subAgentMaxSteps: 10, browserMcp: false };
service.defaultTimeZone = 'UTC';
service.eventBus = {};
service.logger = {};
service.telemetry = { track: jest.fn() };
service.oauth2CallbackUrl = 'http://localhost/rest/oauth2-credential/callback';
service.webhookBaseUrl = 'http://localhost/webhook';
service.formBaseUrl = 'http://localhost/form';
service.runState = {
touchActiveRun: jest.fn(),
registerPendingConfirmation: jest.fn(),
};
service.spawnBackgroundTask = jest.fn();
service.cancelBackgroundTask = jest.fn();
service.backgroundTasks = { touchTask: jest.fn() };
service.schedulePlannedTasks = jest.fn();
service.sendCorrectionToTask = jest.fn();
service.sandboxes = new Map();
service.sandboxCreations = new Map();
service.domainAccessTrackersByThread = new Map();
service.resolveSandboxConfig = jest.fn(async (_user: User) => daytonaSandboxConfig);
(createAllTools as jest.Mock).mockReturnValue(new Map());
const sandbox = { id: 'sandbox-1' };
const workspace = {
init: jest.fn(async () => {}),
destroy: jest.fn(async () => {}),
};
(createSandbox as jest.Mock).mockResolvedValue(sandbox);
(createWorkspace as jest.Mock).mockReturnValue(workspace);
(setupSandboxWorkspace as jest.Mock).mockResolvedValue(undefined);
const environment = await service.createExecutionEnvironment(
fakeUser,
'thread-1',
'run-1',
new AbortController().signal,
);
expect(createLazyRuntimeWorkspace).toHaveBeenCalledTimes(1);
expect(createSandbox).not.toHaveBeenCalled();
const lazyWorkspace = environment.orchestrationContext.workspace as {
ensureWorkspace: () => Promise<unknown>;
};
await lazyWorkspace.ensureWorkspace();
expect(createSandbox).toHaveBeenCalledTimes(1);
expect(createSandbox).toHaveBeenCalledWith(
expect.objectContaining({
id: 'instance-ai-thread-thread-1',
name: 'instance-ai-thread-thread-1',
}),
);
expect(createWorkspace).toHaveBeenCalledTimes(1);
expect(workspace.init).toHaveBeenCalledTimes(1);
expect(setupSandboxWorkspace).toHaveBeenCalledTimes(1);
});
});
describe('InstanceAiService — shutdown', () => {
it('does not destroy thread-scoped sandboxes on service shutdown', async () => {
const service = Object.create(
InstanceAiService.prototype,
) as unknown as ShutdownServiceInternals;
const workspace = { destroy: jest.fn(async () => {}) };
service.stopCheckpointPruning = jest.fn();
service.liveness = { shutdown: jest.fn() };
service.runState = {
shutdown: jest.fn(() => ({ activeRuns: [], suspendedRuns: [] })),
};
service.backgroundTasks = { cancelAll: jest.fn(() => []) };
service.traceContextsByRunId = new Map();
service.finalizeRunTracing = jest.fn(
async (_runId: string, _tracing: InstanceAiTraceContext | undefined, _options: unknown) => {},
);
service.finalizeBackgroundTaskTracing = jest.fn(
async (_task: ManagedBackgroundTask, _status: 'cancelled') => {},
);
service.finalizeRemainingMessageTraceRoots = jest.fn(
async (_threadId: string, _options: unknown) => {},
);
service.gatewayRegistry = { disconnectAll: jest.fn() };
service.sandboxes = new Map([['thread-a', { sandbox: { id: 'sandbox-a' }, workspace }]]);
service.domainAccessTrackersByThread = new Map();
service.eventBus = { clear: jest.fn() };
service._mcpClientManager = { disconnect: jest.fn(async () => {}) };
service.logger = { debug: jest.fn(), warn: jest.fn() };
await service.shutdown();
expect(workspace.destroy).not.toHaveBeenCalled();
expect(service.sandboxes.has('thread-a')).toBe(true);
});
});
describe('InstanceAiService — background task auto-follow-up', () => {
it('starts an internal follow-up when the last direct background task settles normally', async () => {
const { service, task, getSpawnOptions } = createBackgroundTaskFollowUpService();

View File

@ -8,10 +8,10 @@ jest.mock('@n8n/instance-ai', () => {
disconnect = jest.fn();
},
createDomainAccessTracker: jest.fn(),
BuilderSandboxFactory: class {},
SnapshotManager: class {},
createSandbox: jest.fn(),
createWorkspace: jest.fn(),
createLazyRuntimeWorkspace: jest.fn(),
setupSandboxWorkspace: jest.fn(),
workflowBuildOutcomeSchema: z.object({}),
handleBuildOutcome: jest.fn(),
handleVerificationVerdict: jest.fn(),
@ -70,7 +70,6 @@ describe('InstanceAiService — threadPushRef lifetime', () => {
eventBus: { clearThread: jest.Mock };
finalizeRemainingMessageTraceRoots: jest.Mock;
deleteTraceContextsForThread: jest.Mock;
builderSandboxSessions: { cleanupThread: jest.Mock };
destroySandbox: jest.Mock;
reapAiTemporaryForThreadCleanup: jest.Mock;
clearThreadState: (threadId: string) => Promise<void>;
@ -89,7 +88,6 @@ describe('InstanceAiService — threadPushRef lifetime', () => {
service.eventBus = { clearThread: jest.fn() };
service.finalizeRemainingMessageTraceRoots = jest.fn(async () => {});
service.deleteTraceContextsForThread = jest.fn();
service.builderSandboxSessions = { cleanupThread: jest.fn(async () => {}) };
service.destroySandbox = jest.fn(async () => {});
service.reapAiTemporaryForThreadCleanup = jest.fn(async () => {});

View File

@ -12,11 +12,11 @@ import {
type ToolCategory,
type TaskList,
} from '@n8n/api-types';
import type { Message } from '@n8n/agents';
import type { Message, Workspace } from '@n8n/agents';
import { Logger } from '@n8n/backend-common';
import { GlobalConfig, SsrfProtectionConfig, type InstanceAiConfig } from '@n8n/config';
import { OnLeaderStepdown, OnLeaderTakeover } from '@n8n/decorators';
import { ErrorReporter, InstanceSettings } from 'n8n-core';
import { InstanceSettings } from 'n8n-core';
import { SsrfProtectionService } from '@/services/ssrf/ssrf-protection.service';
import { AiBuilderTemporaryWorkflowRepository, UserRepository, type User } from '@n8n/db';
@ -28,14 +28,14 @@ import {
createAllTools,
createSandbox,
createWorkspace,
createLazyRuntimeWorkspace,
setupSandboxWorkspace,
createInstanceAiTraceContext,
createInternalOperationTraceContext,
continueInstanceAiTraceContext,
createInstanceAiLivenessPolicyConfig,
InstanceAiLivenessPolicy,
McpClientManager,
BuilderSandboxFactory,
SnapshotManager,
createDomainAccessTracker,
BackgroundTaskManager,
buildAgentTreeFromEvents,
@ -49,7 +49,6 @@ import {
TerminalOutcomeStorage,
applyPlannedTaskPermissions,
PLANNED_TASK_PERMISSION_OVERRIDES,
BuilderSandboxSessionRegistry,
releaseTraceClient,
submitLangsmithUserFeedback,
resumeAgentRun,
@ -65,6 +64,7 @@ import {
type ConfirmationData,
type BuiltMemory,
type DomainAccessTracker,
type InstanceAiContext,
type ManagedBackgroundTask,
type McpServerConfig,
type ModelConfig,
@ -147,6 +147,28 @@ function isTextMessagePart(part: unknown): part is { type: 'text'; text: string
const ORCHESTRATOR_AGENT_ID = 'agent-001';
type RuntimeSandboxEntry = {
sandbox: NonNullable<Awaited<ReturnType<typeof createSandbox>>>;
workspace: NonNullable<ReturnType<typeof createWorkspace>>;
setupComplete: boolean;
setupPromise: Promise<void> | undefined;
};
function getThreadScopedSandboxName(threadId: string): string {
return `instance-ai-thread-${threadId}`;
}
function withThreadScopedSandboxIdentity(config: SandboxConfig, threadId: string): SandboxConfig {
if (!config.enabled || config.provider !== 'daytona') return config;
const name = getThreadScopedSandboxName(threadId);
return {
...config,
id: name,
name,
};
}
function getUserFacingErrorMessage(error: unknown): string {
if (error instanceof UserError) {
return error.message;
@ -406,8 +428,6 @@ export class InstanceAiService {
MAX_CONCURRENT_BACKGROUND_TASKS_PER_THREAD,
);
private readonly builderSandboxSessions: BuilderSandboxSessionRegistry;
/** Trace contexts keyed by the n8n run ID that started the orchestration turn. */
private readonly traceContextsByRunId = new Map<
string,
@ -418,14 +438,15 @@ export class InstanceAiService {
traceSlug?: string;
}
>();
/** Active sandboxes keyed by thread ID — persisted across messages within a conversation. */
private readonly sandboxes = new Map<
string,
{
sandbox: Awaited<ReturnType<typeof createSandbox>>;
workspace: ReturnType<typeof createWorkspace>;
}
>();
/**
* Shared runtime workspaces keyed by thread ID. This is only an in-process
* cache; deterministic sandbox names let providers reconnect after restart
* or from another main when the thread uses the workspace again.
*/
private readonly sandboxes = new Map<string, RuntimeSandboxEntry>();
/** In-flight runtime workspace creations keyed by thread ID. */
private readonly sandboxCreations = new Map<string, Promise<RuntimeSandboxEntry | undefined>>();
/** Per-user Local Gateway connections. Handles pairing tokens, session keys, and tool dispatch. */
private readonly gatewayRegistry = new LocalGatewayRegistry();
@ -488,7 +509,6 @@ export class InstanceAiService {
private readonly telemetry: Telemetry,
private readonly userRepository: UserRepository,
private readonly aiBuilderTemporaryWorkflowRepository: AiBuilderTemporaryWorkflowRepository,
private readonly errorReporter: ErrorReporter,
ssrfProtectionConfig: SsrfProtectionConfig,
ssrfProtectionService: SsrfProtectionService,
private readonly eventService: EventService,
@ -509,9 +529,6 @@ export class InstanceAiService {
void this.finalizeCancelledSuspendedRun(suspended, reason);
},
});
this.builderSandboxSessions = new BuilderSandboxSessionRegistry(
this.instanceAiConfig.builderSandboxTtlMs,
);
this.defaultTimeZone = globalConfig.generic.timezone;
const restEndpoint = globalConfig.endpoints.rest;
this.oauth2CallbackUrl = `${this.urlService.getInstanceBaseUrl()}/${restEndpoint}/oauth2-credential/callback`;
@ -638,49 +655,92 @@ export class InstanceAiService {
return base;
}
private async createBuilderFactory(user: User): Promise<BuilderSandboxFactory | undefined> {
const config = await this.resolveSandboxConfig(user);
if (!config.enabled) return undefined;
if (config.provider === 'daytona') {
return new BuilderSandboxFactory(
config,
new SnapshotManager(config.image, this.logger, config.n8nVersion, this.errorReporter),
this.logger,
this.errorReporter,
);
/** Get or create the shared runtime sandbox + workspace for a thread. */
private async getOrCreateWorkspace(
threadId: string,
user: User,
context: InstanceAiContext,
): Promise<RuntimeSandboxEntry | undefined> {
const existing = this.sandboxes.get(threadId);
if (existing) {
await this.ensureWorkspaceSetup(existing, context);
return existing;
}
return new BuilderSandboxFactory(config, undefined, this.logger);
const pending = this.sandboxCreations.get(threadId);
if (pending) {
const entry = await pending;
if (entry) await this.ensureWorkspaceSetup(entry, context);
return entry;
}
const creation = this.createWorkspaceEntry(threadId, user);
this.sandboxCreations.set(threadId, creation);
try {
const entry = await creation;
if (entry) await this.ensureWorkspaceSetup(entry, context);
return entry;
} finally {
this.sandboxCreations.delete(threadId);
}
}
/** Get or create a sandbox + workspace for a thread. Returns undefined when sandbox is disabled. */
private async getOrCreateWorkspace(threadId: string, user: User) {
const existing = this.sandboxes.get(threadId);
if (existing) return existing;
private async ensureWorkspaceSetup(
entry: RuntimeSandboxEntry,
context: InstanceAiContext,
): Promise<void> {
if (entry.setupComplete) return;
const config = await this.resolveSandboxConfig(user);
entry.setupPromise ??= setupSandboxWorkspace(entry.workspace, context)
.then(() => {
entry.setupComplete = true;
})
.finally(() => {
entry.setupPromise = undefined;
});
await entry.setupPromise;
}
private async createWorkspaceEntry(
threadId: string,
user: User,
): Promise<RuntimeSandboxEntry | undefined> {
const config = withThreadScopedSandboxIdentity(await this.resolveSandboxConfig(user), threadId);
if (!config.enabled) return undefined;
const sandbox = await createSandbox(config);
const workspace = createWorkspace(sandbox);
if (!sandbox || !workspace) return undefined;
try {
await workspace.init();
} catch (error) {
try {
await workspace.destroy();
} catch {
// Best-effort cleanup when the sandbox cannot start
}
throw error;
}
const entry = { sandbox, workspace };
const entry: RuntimeSandboxEntry = {
sandbox,
workspace,
setupComplete: false,
setupPromise: undefined,
};
this.sandboxes.set(threadId, entry);
return entry;
}
/** Destroy and remove the sandbox for a thread. */
/** Destroy and remove the shared runtime workspace for a thread. */
private async destroySandbox(threadId: string): Promise<void> {
const entry = this.sandboxes.get(threadId);
if (!entry?.sandbox) return;
this.sandboxes.delete(threadId);
try {
if ('destroy' in entry.sandbox && typeof entry.sandbox.destroy === 'function') {
await (entry.sandbox.destroy as () => Promise<void>)();
}
await entry.workspace?.destroy();
} catch (error) {
this.logger.warn('Failed to destroy sandbox', {
threadId,
@ -1681,7 +1741,6 @@ export class InstanceAiService {
this.domainAccessTrackersByThread.delete(threadId);
this.threadPushRef.delete(threadId);
this.deleteTraceContextsForThread(threadId);
await this.builderSandboxSessions.cleanupThread(threadId, 'thread_cleared');
await this.destroySandbox(threadId);
await this.reapAiTemporaryForThreadCleanup(threadId);
this.eventBus.clearThread(threadId);
@ -1723,14 +1782,8 @@ export class InstanceAiService {
this.gatewayRegistry.disconnectAll();
// Destroy all active sandboxes
const sandboxCleanups = [...this.sandboxes.keys()].map(
async (threadId) => await this.destroySandbox(threadId),
);
await Promise.allSettled([
...sandboxCleanups,
this.builderSandboxSessions.cleanupAll('service_shutdown'),
]);
// Thread-scoped sandboxes survive service shutdown so a restarted process
// can reuse them. Thread deletion remains the teardown path.
this.domainAccessTrackersByThread.clear();
this.traceContextsByRunId.clear();
@ -2292,8 +2345,8 @@ export class InstanceAiService {
messageGroupId?: string,
pushRef?: string,
) {
const localGatewayDisabledGlobally =
this.settingsService.getAdminSettings().localGatewayDisabled;
const adminSettings = this.settingsService.getAdminSettings();
const localGatewayDisabledGlobally = adminSettings.localGatewayDisabled;
const localGatewayDisabledForUser = await this.settingsService.isLocalGatewayDisabledForUser(
user.id,
);
@ -2394,7 +2447,24 @@ export class InstanceAiService {
}
const domainTools = createAllTools(context);
const sandboxEntry = await this.getOrCreateWorkspace(threadId, user);
let runtimeWorkspace: Workspace | undefined;
if (adminSettings.sandboxEnabled) {
let sandboxEntryPromise: Promise<RuntimeSandboxEntry | undefined> | undefined;
const getSandboxEntry = async () => {
sandboxEntryPromise ??= this.getOrCreateWorkspace(threadId, user, context).catch(
(error: unknown) => {
sandboxEntryPromise = undefined;
throw error;
},
);
return await sandboxEntryPromise;
};
runtimeWorkspace = createLazyRuntimeWorkspace({
ensureWorkspace: async () => (await getSandboxEntry())?.workspace,
});
}
const orchestrationContext: OrchestrationContext = {
threadId,
@ -2451,9 +2521,7 @@ export class InstanceAiService {
sendCorrectionToTask: (taskId, correction) =>
this.sendCorrectionToTask(threadId, taskId, correction),
workflowTaskService: workflowTasks,
workspace: sandboxEntry?.workspace,
builderSandboxFactory: await this.createBuilderFactory(user),
builderSandboxSessionRegistry: this.builderSandboxSessions,
workspace: runtimeWorkspace,
nodeDefinitionDirs: nodeDefDirs.length > 0 ? nodeDefDirs : undefined,
domainContext: context,
tracingProxyConfig,
@ -2470,7 +2538,6 @@ export class InstanceAiService {
plannedTaskService,
modelId,
orchestrationContext,
sandboxEntry,
};
}