mirror of
https://github.com/n8n-io/n8n.git
synced 2026-06-03 18:27:09 +02:00
feat(core): Make sandbox thread-scoped and lazy-initialize it on Instance AI (#30904)
This commit is contained in:
parent
affc3c1806
commit
eba7d056c5
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -128,6 +128,7 @@ export interface WorkspaceSandbox {
|
|||
readonly provider: string;
|
||||
status: ProviderStatus;
|
||||
getInstructions?(): string;
|
||||
getDefaultCommandEnv?(): NodeJS.ProcessEnv;
|
||||
executeCommand?(
|
||||
command: string,
|
||||
args?: string[],
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -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 = () => {};
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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 } : {}),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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[],
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 () => {});
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user