Compare commits

..
Author SHA1 Message Date
盐粒 Yanli 37b3c30e47 fix(docker): remove sandbox bind mounts 2026-07-09 20:52:51 +08:00
21 changed files with 97 additions and 516 deletions
-3
View File
@@ -99,7 +99,6 @@ jobs:
- '.nvmrc'
- 'docker/docker-compose.middleware.yaml'
- 'docker/envs/middleware.env.example'
- '.github/workflows/main-ci.yml'
- '.github/workflows/web-e2e.yml'
- '.github/actions/setup-web/**'
vdb:
@@ -327,8 +326,6 @@ jobs:
- check-changes
if: needs.pre_job.outputs.should_skip != 'true' && needs.check-changes.outputs.e2e-changed == 'true'
uses: ./.github/workflows/web-e2e.yml
with:
run-external-runtime: true
secrets: inherit
web-e2e-skip:
+1 -10
View File
@@ -96,15 +96,6 @@ jobs:
vp run e2e:external:prepare
vp run e2e:external
- name: Print E2E log tails
if: ${{ failure() && inputs.run-external-runtime }}
run: |
while IFS= read -r log_file; do
echo "::group::${log_file}"
tail -n 200 "${log_file}"
echo "::endgroup::"
done < <(find e2e/.logs -type f | sort)
- name: Upload Cucumber report
if: ${{ !cancelled() }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
@@ -120,5 +111,5 @@ jobs:
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: e2e-logs
path: e2e/.logs/**
path: e2e/.logs
retention-days: 7
+5 -6
View File
@@ -103,17 +103,16 @@ class ApiTool(Tool):
elif not isinstance(credentials["api_key_value"], str):
raise ToolProviderCredentialValidationError("api_key_value must be a string")
api_key_value = credentials["api_key_value"]
if "api_key_header_prefix" in credentials:
api_key_header_prefix = credentials["api_key_header_prefix"]
if api_key_header_prefix == "basic" and api_key_value:
api_key_value = f"Basic {api_key_value}"
elif api_key_header_prefix == "bearer" and api_key_value:
api_key_value = f"Bearer {api_key_value}"
if api_key_header_prefix == "basic" and credentials["api_key_value"]:
credentials["api_key_value"] = f"Basic {credentials['api_key_value']}"
elif api_key_header_prefix == "bearer" and credentials["api_key_value"]:
credentials["api_key_value"] = f"Bearer {credentials['api_key_value']}"
elif api_key_header_prefix == "custom":
pass
headers[api_key_header] = api_key_value
headers[api_key_header] = credentials["api_key_value"]
elif credentials["auth_type"] == "api_key_query":
# For query parameter authentication, we don't add anything to headers
@@ -85,10 +85,6 @@ def test_assembling_request_auth_header_assembly():
assert headers["Authorization"] == "Bearer abc"
tool.runtime.credentials = {"auth_type": "api_key_header", "api_key_header_prefix": "basic", "api_key_value": "abc"}
headers = tool.assembling_request(parameters={})
assert headers["Authorization"] == "Basic abc"
assert tool.runtime.credentials["api_key_value"] == "abc"
headers = tool.assembling_request(parameters={})
assert headers["Authorization"] == "Basic abc"
@@ -60,7 +60,6 @@ class _HasErrorCode(Protocol):
DEFAULT_TIMEOUT_SECONDS = 30.0
DEFAULT_TERMINATE_GRACE_SECONDS = 10.0
_INTERNAL_COMMAND_TIMEOUT_SECONDS = 90.0
_WORKSPACE_ROOT = "~/workspace"
_WORKSPACE_DIR_NAME = "workspace"
_WORKSPACE_COLLISION_EXIT_CODE = 17
@@ -560,7 +559,7 @@ class DifyShellLayer(PydanticAILayer[DifyShellLayerDeps, object, DifyShellLayerC
script,
cwd=cwd,
env=self._build_shell_command_env(include_agent_stub_env=False),
timeout=_INTERNAL_COMMAND_TIMEOUT_SECONDS,
timeout=DEFAULT_TIMEOUT_SECONDS,
max_output_bytes=_REMOTE_COMPLETE_OUTPUT_MAX_BYTES,
)
-2
View File
@@ -65,8 +65,6 @@ class ShellctlConfig:
poll_interval_seconds: float = 0.05
pipe_monitor_interval_seconds: float = 1.0
pipe_ready_timeout_seconds: float = 10.0
tmux_command_timeout_seconds: float = 15.0
tmux_session_start_timeout_seconds: float = 90.0
sqlite_busy_timeout_ms: int = DEFAULT_SQLITE_BUSY_TIMEOUT_MS
sanitize_pty_command: tuple[str, ...] = ("shellctl-sanitize-pty",)
runner_exit_command: tuple[str, ...] = ("shellctl-runner-exit",)
@@ -12,7 +12,6 @@ from __future__ import annotations
import asyncio
import json
import logging
import shlex
import shutil
import stat
@@ -75,9 +74,6 @@ from shellctl.shared.schemas import (
)
logger = logging.getLogger(__name__)
class ShellctlService:
"""SQLite-backed shellctl job service used by HTTP and local server entrypoints.
@@ -277,7 +273,6 @@ class ShellctlService:
require_exit_code_null=True,
)
except Exception as exc:
logger.warning("shellctl job %s failed during startup: %s", job_id, exc, exc_info=True)
await self._transition_status(
job_id,
allowed_from={
+24 -90
View File
@@ -124,31 +124,20 @@ class TmuxController:
str(cwd),
]
)
session_name = job_session_name(job_id)
try:
result = await self._run_tmux(
"-f",
"/dev/null",
"new-session",
"-d",
"-s",
session_name,
"-x",
str(terminal.cols),
"-y",
str(terminal.rows),
runner_command,
check=False,
timeout_seconds=self._config.tmux_session_start_timeout_seconds,
)
except ShellctlServerError as exc:
if exc.code == "tmux_timeout":
session_probe = await self._session_exists_after_start_timeout(session_name)
if session_probe is True:
return
message = f"{exc.message}; session_probe={self._format_optional_probe(session_probe)}"
raise ShellctlServerError(exc.status_code, exc.code, message) from exc
raise
result = await self._run_tmux(
"-f",
"/dev/null",
"new-session",
"-d",
"-s",
job_session_name(job_id),
"-x",
str(terminal.cols),
"-y",
str(terminal.rows),
runner_command,
check=False,
)
if result.returncode != 0:
raise ShellctlServerError(
500,
@@ -310,36 +299,19 @@ class TmuxController:
check=False,
)
async def _run_tmux(
self,
*args: str,
check: bool = True,
timeout_seconds: float | None = None,
) -> subprocess.CompletedProcess[bytes]:
command = ["tmux", "-S", str(self._config.tmux_socket), *args]
effective_timeout = (
timeout_seconds if timeout_seconds is not None else self._config.tmux_command_timeout_seconds
)
async def _run_tmux(self, *args: str, check: bool = True) -> subprocess.CompletedProcess[bytes]:
env = dict(os.environ)
env.pop("TMUX", None)
try:
with anyio.fail_after(effective_timeout):
result = await anyio.run_process(
command,
env=self._tmux_env(),
check=False,
stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
start_new_session=True,
)
result = await anyio.run_process(
["tmux", "-S", str(self._config.tmux_socket), *args],
env=env,
check=False,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
except FileNotFoundError as exc:
raise ShellctlServerError(500, "tmux_not_installed", "tmux executable was not found") from exc
except TimeoutError as exc:
message = f"tmux command timed out after {effective_timeout:.3f}s: {self._shell_join(command)}"
raise ShellctlServerError(
504,
"tmux_timeout",
message,
) from exc
if check and result.returncode != 0:
raise ShellctlServerError(
500,
@@ -348,48 +320,10 @@ class TmuxController:
)
return result
async def _session_exists_after_start_timeout(self, session_name: str) -> bool | None:
try:
with anyio.fail_after(min(5.0, self._config.tmux_command_timeout_seconds)):
result = await anyio.run_process(
[
"tmux",
"-S",
str(self._config.tmux_socket),
"list-sessions",
"-F",
"#{session_name}",
],
env=self._tmux_env(),
check=False,
stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
start_new_session=True,
)
except (FileNotFoundError, TimeoutError):
return None
if result.returncode != 0:
return None
output = result.stdout.decode("utf-8", errors="replace")
return session_name in {line.strip() for line in output.splitlines() if line.strip()}
@staticmethod
def _format_optional_probe(probe: bool | None) -> str:
if probe is None:
return "unavailable"
return "found" if probe else "missing"
@staticmethod
def _shell_join(parts: tuple[str, ...] | list[str]) -> str:
return " ".join(shlex.quote(part) for part in parts)
@staticmethod
def _tmux_env() -> dict[str, str]:
env = dict(os.environ)
env.pop("TMUX", None)
return env
def _tmux_target_missing(stderr: str) -> bool:
normalized = stderr.lower()
@@ -319,12 +319,6 @@ def test_shell_layer_create_allocates_workspace_and_bootstraps(monkeypatch: pyte
assert layer.runtime_state.session_id == "abc12ff"
assert layer.runtime_state.workspace_cwd == "~/workspace/abc12ff"
assert [call.job_id for call in provider.resource.commands.delete_calls] == ["mkdir-job", "bootstrap-job"]
assert [call.timeout for call in provider.resource.commands.run_calls] == pytest.approx(
[
shell_layer_module._INTERNAL_COMMAND_TIMEOUT_SECONDS,
shell_layer_module._INTERNAL_COMMAND_TIMEOUT_SECONDS,
]
)
def test_shell_layer_uses_agent_specific_home_and_workspace_cwd(
@@ -1583,136 +1583,10 @@ def test_shellctl_config_defaults_to_lightweight_sanitize_entrypoint(
config = ShellctlConfig(state_dir=tmp_path / "state", runtime_dir=tmp_path / "run")
assert config.pipe_ready_timeout_seconds == 10.0
assert config.tmux_command_timeout_seconds == 15.0
assert config.tmux_session_start_timeout_seconds == 90.0
assert config.sanitize_pty_command == ("shellctl-sanitize-pty",)
assert config.runner_exit_command == ("shellctl-runner-exit",)
@pytest.mark.anyio
async def test_tmux_command_timeout_returns_structured_error(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
async def slow_run_process(*args: object, **kwargs: object) -> subprocess.CompletedProcess[bytes]:
del args, kwargs
await anyio.sleep(1)
return subprocess.CompletedProcess(["tmux"], 0, stdout=b"", stderr=b"")
monkeypatch.setattr(anyio, "run_process", slow_run_process)
controller = TmuxController(
ShellctlConfig(
state_dir=tmp_path / "state",
runtime_dir=tmp_path / "run",
tmux_command_timeout_seconds=0.01,
)
)
with pytest.raises(ShellctlServerError) as exc_info:
await controller.list_sessions()
assert exc_info.value.status_code == 504
assert exc_info.value.code == "tmux_timeout"
assert "tmux command timed out after 0.010s" in exc_info.value.message
assert "list-sessions" in exc_info.value.message
@pytest.mark.anyio
async def test_tmux_commands_do_not_inherit_process_stdin_or_session(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
captured_kwargs: dict[str, object] = {}
async def fake_run_process(*args: object, **kwargs: object) -> subprocess.CompletedProcess[bytes]:
del args
captured_kwargs.update(kwargs)
return subprocess.CompletedProcess(["tmux"], 0, stdout=b"", stderr=b"")
monkeypatch.setattr(anyio, "run_process", fake_run_process)
controller = TmuxController(
ShellctlConfig(
state_dir=tmp_path / "state",
runtime_dir=tmp_path / "run",
)
)
await controller.start_server()
assert captured_kwargs["stdin"] == subprocess.DEVNULL
assert captured_kwargs["start_new_session"] is True
@pytest.mark.anyio
async def test_tmux_session_start_uses_dedicated_timeout(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
async def slow_run_process(*args: object, **kwargs: object) -> subprocess.CompletedProcess[bytes]:
del args, kwargs
await anyio.sleep(1)
return subprocess.CompletedProcess(["tmux"], 0, stdout=b"", stderr=b"")
monkeypatch.setattr(anyio, "run_process", slow_run_process)
controller = TmuxController(
ShellctlConfig(
state_dir=tmp_path / "state",
runtime_dir=tmp_path / "run",
tmux_command_timeout_seconds=30.0,
tmux_session_start_timeout_seconds=0.01,
)
)
with pytest.raises(ShellctlServerError) as exc_info:
await controller.create_job_session(
job_id="job-timeout",
job_dir=tmp_path / "job-timeout",
cwd=tmp_path,
terminal=TerminalSize(cols=120, rows=80),
)
assert exc_info.value.status_code == 504
assert exc_info.value.code == "tmux_timeout"
assert "tmux command timed out after 0.010s" in exc_info.value.message
assert "new-session" in exc_info.value.message
@pytest.mark.anyio
async def test_tmux_session_start_timeout_succeeds_when_session_was_created(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
session_name = job_session_name("job-timeout")
async def fake_run_process(
command: list[str],
**kwargs: object,
) -> subprocess.CompletedProcess[bytes]:
del kwargs
if "new-session" in command:
await anyio.sleep(1)
return subprocess.CompletedProcess(command, 0, stdout=b"", stderr=b"")
if "list-sessions" in command:
return subprocess.CompletedProcess(command, 0, stdout=f"{session_name}\n".encode(), stderr=b"")
return subprocess.CompletedProcess(command, 1, stdout=b"", stderr=b"unexpected command")
monkeypatch.setattr(anyio, "run_process", fake_run_process)
controller = TmuxController(
ShellctlConfig(
state_dir=tmp_path / "state",
runtime_dir=tmp_path / "run",
tmux_session_start_timeout_seconds=0.01,
)
)
await controller.create_job_session(
job_id="job-timeout",
job_dir=tmp_path / "job-timeout",
cwd=tmp_path,
terminal=TerminalSize(cols=120, rows=80),
)
def test_pipe_command_finalizer_commits_runner_exit_after_drain(tmp_path: Path) -> None:
controller = TmuxController(
ShellctlConfig(
-3
View File
@@ -514,9 +514,6 @@ services:
HTTPS_PROXY: ${SANDBOX_HTTPS_PROXY:-http://ssrf_proxy:3128}
SANDBOX_PORT: ${SANDBOX_PORT:-8194}
PIP_MIRROR_URL: ${PIP_MIRROR_URL:-}
volumes:
- ./volumes/sandbox/dependencies:/dependencies
- ./volumes/sandbox/conf:/conf
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8194/health"]
networks:
-3
View File
@@ -119,9 +119,6 @@ services:
HTTPS_PROXY: ${SANDBOX_HTTPS_PROXY:-http://ssrf_proxy:3128}
SANDBOX_PORT: ${SANDBOX_PORT:-8194}
PIP_MIRROR_URL: ${PIP_MIRROR_URL:-}
volumes:
- ./volumes/sandbox/dependencies:/dependencies
- ./volumes/sandbox/conf:/conf
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8194/health"]
networks:
-3
View File
@@ -520,9 +520,6 @@ services:
HTTPS_PROXY: ${SANDBOX_HTTPS_PROXY:-http://ssrf_proxy:3128}
SANDBOX_PORT: ${SANDBOX_PORT:-8194}
PIP_MIRROR_URL: ${PIP_MIRROR_URL:-}
volumes:
- ./volumes/sandbox/dependencies:/dependencies
- ./volumes/sandbox/conf:/conf
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8194/health"]
networks:
+2
View File
@@ -5,6 +5,8 @@ app:
max_workers: 4
max_requests: 50
worker_timeout: 5
python_path: /opt/python/bin/python3
nodejs_path: /usr/local/bin/node
enable_network: True # please make sure there is no network risk in your environment
allowed_syscalls: # please leave it empty if you have no idea how seccomp works
proxy:
+13 -67
View File
@@ -6,11 +6,11 @@ import type {
ChatRequestPayloadWithUser,
PostChatMessagesResponse,
} from '@dify/contracts/api/service/types.gen'
import type { APIResponse } from '@playwright/test'
import { request } from '@playwright/test'
import { createApiContext, expectApiResponseOK, setAppSiteEnabled } from '../../../support/api'
import { getTestAgent } from './agent'
const SERVICE_API_STREAM_TIMEOUT_MS = 120_000
export type AgentServiceApiChatResult = {
body: PostChatMessagesResponse | unknown
ok: boolean
@@ -22,13 +22,12 @@ type ServiceApiSseEvent = {
event?: string
}
async function parseServiceApiChatResponse(response: Response) {
const contentType = response.headers.get('content-type') ?? ''
async function parseServiceApiChatResponse(response: APIResponse) {
const contentType = response.headers()['content-type'] ?? ''
const text = await response.text().catch(() => '')
if (contentType.includes('text/event-stream'))
return parseServiceApiSseStream(response)
const text = await response.text().catch(() => '')
return parseServiceApiSseText(text)
if (contentType.includes('application/json')) {
try {
@@ -47,36 +46,6 @@ async function parseServiceApiChatResponse(response: Response) {
}
}
async function parseServiceApiSseStream(response: Response) {
if (!response.body)
return parseServiceApiSseText(await response.text().catch(() => ''))
const reader = response.body.getReader()
const decoder = new TextDecoder()
let raw = ''
try {
while (true) {
const { done, value } = await reader.read()
if (done)
break
raw += decoder.decode(value, { stream: true })
const parsed = parseServiceApiSseText(raw)
if (parsed.events.some(isTerminalServiceApiEvent)) {
await reader.cancel().catch(() => {})
return parsed
}
}
}
finally {
reader.releaseLock()
}
raw += decoder.decode()
return parseServiceApiSseText(raw)
}
function parseServiceApiSseText(text: string) {
const events: ServiceApiSseEvent[] = []
const answers: string[] = []
@@ -126,24 +95,6 @@ function parseServiceApiSseText(text: string) {
}
}
function isTerminalServiceApiEvent(event: ServiceApiSseEvent) {
if (event.event === 'message_end' || event.event === 'workflow_finished' || event.event === 'error')
return true
const data = event.data
return Boolean(
data
&& typeof data === 'object'
&& !Array.isArray(data)
&& 'event' in data
&& (
data.event === 'message_end'
|| data.event === 'workflow_finished'
|| data.event === 'error'
),
)
}
export async function setAgentSiteAccessAndGetURL(
agentId: string,
enabled: boolean,
@@ -201,6 +152,7 @@ export async function sendAgentServiceApiChatMessage({
query?: string
serviceApiBaseURL: string
}): Promise<AgentServiceApiChatResult> {
const ctx = await request.newContext()
const body = {
inputs: {},
query,
@@ -208,28 +160,22 @@ export async function sendAgentServiceApiChatMessage({
user: 'e2e-agent-access-point',
} satisfies ChatRequestPayloadWithUser
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), SERVICE_API_STREAM_TIMEOUT_MS)
try {
const response = await fetch(`${serviceApiBaseURL.replace(/\/$/, '')}/chat-messages`, {
body: JSON.stringify(body),
const response = await ctx.post(`${serviceApiBaseURL.replace(/\/$/, '')}/chat-messages`, {
data: body,
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`,
},
method: 'POST',
signal: controller.signal,
})
const responseBody = await parseServiceApiChatResponse(response)
return {
body: responseBody as PostChatMessagesResponse | unknown,
ok: response.ok,
status: response.status,
ok: response.ok(),
status: response.status(),
}
}
finally {
clearTimeout(timeout)
await ctx.dispose()
}
}
@@ -9,8 +9,6 @@ import {
import { agentBuilderExpectedTokens, agentBuilderFixedInputs } from '../../agent-v2/support/agent-builder-resources'
import { getCurrentAgentId, getServiceApiCard } from './access-point-helpers'
const SERVICE_API_RUNTIME_STEP_TIMEOUT_MS = 130_000
async function enableAgentApiAccessWithKey(world: DifyWorld) {
const agentId = getCurrentAgentId(world)
const apiAccess = await setAgentApiAccess(agentId, true)
@@ -174,42 +172,34 @@ Then('the Agent v2 API Reference should open in a new tab', async function (this
this.agentBuilder.accessPoint.apiReferencePage = undefined
})
When(
'I send the Agent v2 Backend service API minimal request',
{ timeout: SERVICE_API_RUNTIME_STEP_TIMEOUT_MS },
async function (this: DifyWorld) {
const serviceApiBaseURL = this.agentBuilder.accessPoint.serviceApiBaseURL
const apiKey = this.agentBuilder.accessPoint.generatedApiKey
if (!serviceApiBaseURL)
throw new Error('No Agent v2 service API endpoint found. Enable Backend service API first.')
if (!apiKey)
throw new Error('No Agent v2 API key found. Create a Backend service API key first.')
When('I send the Agent v2 Backend service API minimal request', async function (this: DifyWorld) {
const serviceApiBaseURL = this.agentBuilder.accessPoint.serviceApiBaseURL
const apiKey = this.agentBuilder.accessPoint.generatedApiKey
if (!serviceApiBaseURL)
throw new Error('No Agent v2 service API endpoint found. Enable Backend service API first.')
if (!apiKey)
throw new Error('No Agent v2 API key found. Create a Backend service API key first.')
this.agentBuilder.accessPoint.serviceApiResponse = await sendAgentServiceApiChatMessage({
apiKey,
serviceApiBaseURL,
})
},
)
this.agentBuilder.accessPoint.serviceApiResponse = await sendAgentServiceApiChatMessage({
apiKey,
serviceApiBaseURL,
})
})
When(
'I send the Agent v2 Backend service API knowledge request',
{ timeout: SERVICE_API_RUNTIME_STEP_TIMEOUT_MS },
async function (this: DifyWorld) {
const serviceApiBaseURL = this.agentBuilder.accessPoint.serviceApiBaseURL
const apiKey = this.agentBuilder.accessPoint.generatedApiKey
if (!serviceApiBaseURL)
throw new Error('No Agent v2 service API endpoint found. Enable Backend service API first.')
if (!apiKey)
throw new Error('No Agent v2 API key found. Create a Backend service API key first.')
When('I send the Agent v2 Backend service API knowledge request', async function (this: DifyWorld) {
const serviceApiBaseURL = this.agentBuilder.accessPoint.serviceApiBaseURL
const apiKey = this.agentBuilder.accessPoint.generatedApiKey
if (!serviceApiBaseURL)
throw new Error('No Agent v2 service API endpoint found. Enable Backend service API first.')
if (!apiKey)
throw new Error('No Agent v2 API key found. Create a Backend service API key first.')
this.agentBuilder.accessPoint.serviceApiResponse = await sendAgentServiceApiChatMessage({
apiKey,
query: agentBuilderFixedInputs.knowledgeRuntimeQuery,
serviceApiBaseURL,
})
},
)
this.agentBuilder.accessPoint.serviceApiResponse = await sendAgentServiceApiChatMessage({
apiKey,
query: agentBuilderFixedInputs.knowledgeRuntimeQuery,
serviceApiBaseURL,
})
})
const stringifyServiceApiBody = (body: unknown) => {
try {
@@ -34,7 +34,6 @@ import {
} from './configure-helpers'
const BUILD_DRAFT_RUNTIME_STEP_TIMEOUT_MS = 180_000
const BUILD_DRAFT_CHAT_RESPONSE_TIMEOUT_MS = 150_000
const BUILD_DRAFT_NOTE_SYNC_TIMEOUT_MS = 30_000
const BUILD_NOTE_FILE_NAME = 'build_note.md'
const BUILD_NOTE_MARKER = 'E2E_BUILD_DRAFT_PASS'
@@ -162,7 +161,7 @@ When(
const chatResponsePromise = page.waitForResponse(response => (
response.request().method() === 'POST'
&& new URL(response.url()).pathname.endsWith(`/console/api/agent/${agentId}/chat-messages`)
), { timeout: BUILD_DRAFT_CHAT_RESPONSE_TIMEOUT_MS })
))
await page.getByRole('button', { name: 'Start build' }).click()
expect((await checkoutResponsePromise).ok()).toBe(true)
@@ -12,8 +12,6 @@ import { hasToolEntry } from '../../agent-v2/support/preflight/tools'
import { getPreseededToolContract } from '../../agent-v2/support/tools'
import { expectProviderToolActionVisible, getCurrentAgentId } from './configure-helpers'
const TOOL_RUNTIME_STEP_TIMEOUT_MS = 180_000
const getToolsSection = (world: DifyWorld) =>
world.getPage().getByRole('region', { name: 'Tools' })
@@ -273,28 +271,24 @@ Then(
},
)
When(
'I send the Agent v2 Backend service API JSON Replace request',
{ timeout: TOOL_RUNTIME_STEP_TIMEOUT_MS },
async function (this: DifyWorld) {
const serviceApiBaseURL = this.agentBuilder.accessPoint.serviceApiBaseURL
const apiKey = this.agentBuilder.accessPoint.generatedApiKey
if (!serviceApiBaseURL)
throw new Error('No Agent v2 service API endpoint found. Enable Backend service API first.')
if (!apiKey)
throw new Error('No Agent v2 API key found. Create a Backend service API key first.')
When('I send the Agent v2 Backend service API JSON Replace request', async function (this: DifyWorld) {
const serviceApiBaseURL = this.agentBuilder.accessPoint.serviceApiBaseURL
const apiKey = this.agentBuilder.accessPoint.generatedApiKey
if (!serviceApiBaseURL)
throw new Error('No Agent v2 service API endpoint found. Enable Backend service API first.')
if (!apiKey)
throw new Error('No Agent v2 API key found. Create a Backend service API key first.')
this.agentBuilder.accessPoint.serviceApiResponse = await sendAgentServiceApiChatMessage({
apiKey,
query: [
'Verify JSON Replace now.',
`Replace ${agentBuilderExpectedTokens.jsonToolBefore} with ${agentBuilderExpectedTokens.jsonToolAfter}.`,
`Return the JSON result and include ${agentBuilderExpectedTokens.jsonToolAfter}.`,
].join(' '),
serviceApiBaseURL,
})
},
)
this.agentBuilder.accessPoint.serviceApiResponse = await sendAgentServiceApiChatMessage({
apiKey,
query: [
'Verify JSON Replace now.',
`Replace ${agentBuilderExpectedTokens.jsonToolBefore} with ${agentBuilderExpectedTokens.jsonToolAfter}.`,
`Return the JSON result and include ${agentBuilderExpectedTokens.jsonToolAfter}.`,
].join(' '),
serviceApiBaseURL,
})
})
Then(
'the Agent v2 Backend service API response should include the JSON Replace E2E marker',
+2 -86
View File
@@ -68,87 +68,6 @@ const readLogTail = async (logFilePath: string) => {
.join('\n')
}
const getShellctlAuthHeaders = () => {
const token = process.env.E2E_SHELLCTL_AUTH_TOKEN || process.env.DIFY_AGENT_SHELLCTL_AUTH_TOKEN
return token ? { Authorization: `Bearer ${token}` } : undefined
}
type ShellctlJobResult = {
done?: boolean
exit_code?: number | null
job_id?: string
offset?: number
output?: string
status?: string
}
const prewarmShellctlSandbox = async (shellctlURL: string) => {
const marker = 'shellctl-e2e-ready'
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 210_000)
const deadline = Date.now() + 180_000
let output = ''
let latestBody: ShellctlJobResult | undefined
try {
const response = await fetch(`${shellctlURL}/v1/jobs/run`, {
body: JSON.stringify({
idle_flush_seconds: 0.2,
output_limit: 4096,
script: `printf '${marker}'`,
timeout: 180,
}),
headers: {
...getShellctlAuthHeaders(),
'Content-Type': 'application/json',
},
method: 'POST',
signal: controller.signal,
})
latestBody = await response.json().catch(() => undefined) as ShellctlJobResult | undefined
output += latestBody?.output ?? ''
if (!response.ok)
throw new Error(`Shellctl sandbox prewarm failed: ${response.status} ${JSON.stringify(latestBody)}`)
while (latestBody?.job_id && !latestBody.done && Date.now() < deadline) {
const waitResponse = await fetch(`${shellctlURL}/v1/jobs/${latestBody.job_id}/wait`, {
body: JSON.stringify({
idle_flush_seconds: 0.2,
offset: latestBody.offset ?? output.length,
output_limit: 4096,
timeout: Math.min(30, Math.max(1, Math.ceil((deadline - Date.now()) / 1000))),
}),
headers: {
...getShellctlAuthHeaders(),
'Content-Type': 'application/json',
},
method: 'POST',
signal: controller.signal,
})
latestBody = await waitResponse.json().catch(() => undefined) as ShellctlJobResult | undefined
output += latestBody?.output ?? ''
if (!waitResponse.ok)
throw new Error(`Shellctl sandbox prewarm wait failed: ${waitResponse.status} ${JSON.stringify(latestBody)}`)
}
if (
!latestBody?.done
|| latestBody.exit_code !== 0
|| !output.includes(marker)
) {
throw new Error(
`Shellctl sandbox prewarm failed: ${JSON.stringify(latestBody)} output=${JSON.stringify(output)}`,
)
}
}
finally {
clearTimeout(timeout)
}
}
const waitForUnexpectedProcessExit = async (
managedProcess: ManagedProcess,
shouldIgnoreExit: () => boolean,
@@ -277,20 +196,17 @@ const main = async () => {
let waitingForShellctl = true
try {
const shellctlPort = process.env.E2E_SHELLCTL_PORT || '5004'
const shellctlURL = `http://127.0.0.1:${shellctlPort}`
await Promise.race([
waitForUrl(`${shellctlURL}/openapi.json`, 180_000, 1_000),
waitForUrl(`http://127.0.0.1:${shellctlPort}/openapi.json`, 180_000, 1_000),
waitForUnexpectedProcessExit(shellctlProcess, () => !waitingForShellctl),
])
await prewarmShellctlSandbox(shellctlURL)
}
catch (error) {
if (error instanceof Error && error.message.includes('exited before becoming ready'))
throw error
const detail = error instanceof Error ? error.message : String(error)
throw new Error(
`Shellctl sandbox did not become ready or prewarm successfully: ${detail}. See ${shellctlProcess.logFilePath}.`,
`Shellctl sandbox did not become ready. See ${shellctlProcess.logFilePath}.`,
)
}
finally {
@@ -315,40 +315,6 @@ describe('useAgentConfigureSync', () => {
})
})
it('should save the latest dirty draft when Configure unmounts before autosave runs', async () => {
const { store, unmount } = renderUseAgentConfigureSync()
act(() => {
store.set(agentComposerDraftAtom, {
...defaultAgentSoulConfigFormState,
prompt: 'Route leave prompt',
})
})
expect(composerPutMutationFn).not.toHaveBeenCalled()
await act(async () => {
unmount()
await Promise.resolve()
})
expect(composerPutMutationFn).toHaveBeenCalledTimes(1)
expect(composerPutMutationFn).toHaveBeenCalledWith(expect.objectContaining({
params: {
agent_id: 'agent-1',
},
body: expect.objectContaining({
variant: 'agent_app',
save_strategy: 'save_to_current_version',
agent_soul: expect.objectContaining({
prompt: expect.objectContaining({
system_prompt: 'Route leave prompt',
}),
}),
}),
}))
})
it('should include Agent Soul files when autosaving file changes', async () => {
const { store } = renderUseAgentConfigureSync()
@@ -208,6 +208,12 @@ export function useAgentConfigureSync({
})
}, [debouncedSaveDraft, getAgentSoulDraft, store])
useEffect(() => {
return () => {
debouncedSaveDraft.flush?.()
}
}, [debouncedSaveDraft])
useEffect(() => {
const saveDraftWhenPageHidden = () => {
if (document.visibilityState === 'hidden')
@@ -226,12 +232,6 @@ export function useAgentConfigureSync({
}
}, [saveDirtyDraftOnPageClose])
useEffect(() => {
return () => {
saveDirtyDraftOnPageClose()
}
}, [saveDirtyDraftOnPageClose])
const publishDraft = useCallback(async () => {
if (publishInFlightRef.current)
return