diff --git a/dify-agent/src/dify_agent/layers/shell/layer.py b/dify-agent/src/dify_agent/layers/shell/layer.py
index 5db17d68499..a8f46d628a6 100644
--- a/dify-agent/src/dify_agent/layers/shell/layer.py
+++ b/dify-agent/src/dify_agent/layers/shell/layer.py
@@ -18,9 +18,11 @@ side-effecting ``on_context_resume`` attempt fails after issuing shellctl jobs,
Agenton still exits ``resource_context()`` but never transitions the layer to
``ACTIVE``. In that failed-enter path, normal suspend/delete hooks do not run,
so the enter hook itself must perform best-effort business compensation before
-re-raising the failure. Agent Stub env injection uses shellctl's native per-run
-``env`` argument for user-visible ``shell.run`` and for trusted server-owned
-fixed scripts executed through ``run_remote_script()``.
+re-raising the failure. Agent Soul shell env is injected into user-visible
+commands and CLI bootstrap commands without persisting a workspace env file.
+Agent Stub env injection uses shellctl's native per-run ``env`` argument for
+user-visible ``shell.run`` and for trusted server-owned fixed scripts executed
+through ``run_remote_script()``.
"""
from __future__ import annotations
@@ -475,7 +477,7 @@ class DifyShellLayer(PydanticAILayer[DifyShellLayerDeps, object, DifyShellLayerC
try:
client = self._require_client()
result = await client.run(
- _wrap_user_script(script),
+ _wrap_user_script(script, self.config),
cwd=self._require_workspace_cwd(),
env=self._build_user_shell_run_env(),
timeout=timeout,
@@ -536,9 +538,9 @@ class DifyShellLayer(PydanticAILayer[DifyShellLayerDeps, object, DifyShellLayerC
and optional Agent Stub env injection.
Unlike model-visible ``shell.run``, this server-owned boundary does not
- source ``.dify/env.sh``. That file is user-controlled shell config, so
- sourcing it here would let sandbox code clobber trusted Agent Stub env
- values before ``dify-agent file upload`` executes.
+ inject Agent Soul shell env. Keeping the user-controlled shell env out
+ of this path prevents sandbox code from clobbering trusted Agent Stub
+ env values before ``dify-agent file upload`` executes.
"""
env = None
if inject_agent_stub_env:
@@ -833,16 +835,18 @@ def _workspace_cwd(session_id: str) -> str:
def _workspace_bootstrap_script(config: DifyShellLayerConfig) -> str:
- """Return the workspace bootstrap script for env + CLI tool declarations."""
- has_bootstrap = bool(config.env or config.secret_refs or config.cli_tools or config.sandbox is not None)
- if not has_bootstrap:
+ """Return the workspace bootstrap script for CLI tool declarations."""
+ install_commands = [command for tool in config.cli_tools for command in tool.install_commands]
+ if not install_commands:
return ""
- lines: list[str] = [
- "set -eu",
- 'mkdir -p ".dify"',
- "cat > \".dify/env.sh\" <<'DIFY_ENV_EOF'",
- ]
+ lines: list[str] = ["set -eu", *_shell_config_export_lines(config), *install_commands]
+ return "\n".join(lines)
+
+
+def _shell_config_export_lines(config: DifyShellLayerConfig) -> list[str]:
+ """Return ephemeral Agent Soul shell exports for one shellctl command."""
+ lines: list[str] = []
for env_var in config.env:
lines.append(f"export {env_var.name}={_shquote(env_var.value)}")
for secret_ref in config.secret_refs:
@@ -860,32 +864,15 @@ def _workspace_bootstrap_script(config: DifyShellLayerConfig) -> str:
if config.sandbox.config:
sandbox_config = json.dumps(config.sandbox.config, ensure_ascii=True, sort_keys=True)
lines.append(f"export DIFY_SANDBOX_CONFIG_JSON={_shquote(sandbox_config)}")
- lines.extend(
- [
- "DIFY_ENV_EOF",
- 'chmod 600 ".dify/env.sh"',
- '. ".dify/env.sh"',
- ]
- )
- for tool in config.cli_tools:
- for command in tool.install_commands:
- lines.append(command)
- return "\n".join(lines)
+ return lines
-def _wrap_user_script(script: str) -> str:
- """Source Agent Soul env before executing a model-requested shell command."""
- # TODO: refactor
- return "\n".join(
- [
- 'if [ -f ".dify/env.sh" ]; then',
- " set -a",
- ' . ".dify/env.sh"',
- " set +a",
- "fi",
- script,
- ]
- )
+def _wrap_user_script(script: str, config: DifyShellLayerConfig) -> str:
+ """Inject Agent Soul env before executing a model-requested shell command."""
+ lines = _shell_config_export_lines(config)
+ if not lines:
+ return script
+ return "\n".join([*lines, script])
def _workspace_mkdir_script(*, session_id: str) -> str:
diff --git a/dify-agent/tests/local/dify_agent/layers/shell/test_layer.py b/dify-agent/tests/local/dify_agent/layers/shell/test_layer.py
index 30352d87c5e..c7d2599b63c 100644
--- a/dify-agent/tests/local/dify_agent/layers/shell/test_layer.py
+++ b/dify-agent/tests/local/dify_agent/layers/shell/test_layer.py
@@ -3,6 +3,7 @@ from collections.abc import Callable, Mapping
import secrets
import time
from dataclasses import dataclass
+from typing import cast
import pytest
@@ -454,7 +455,6 @@ def test_shell_layer_create_bootstraps_agent_soul_shell_config(monkeypatch: pyte
assert 'export GITHUB_TOKEN="${GITHUB_TOKEN:-}"' in script
assert "export DIFY_SANDBOX_PROVIDER='independent'" in script
assert "export DIFY_SANDBOX_CONFIG_JSON='{\"cpu\": 2}'" in script
- assert '. ".dify/env.sh"' in script
assert "apt-get install -y ripgrep" in script
return _job_result("bootstrap-job", status=JobStatusName.EXITED, done=True, exit_code=0)
@@ -489,10 +489,60 @@ def test_shell_layer_create_bootstraps_agent_soul_shell_config(monkeypatch: pyte
assert layer.runtime_state.job_ids == ["mkdir-job", "bootstrap-job"]
+def test_shell_layer_injects_agent_soul_env_without_workspace_env_file(monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.setattr(time, "time", lambda: 0xABC12)
+
+ def token_hex(_nbytes: int) -> str:
+ return "ff"
+
+ monkeypatch.setattr(secrets, "token_hex", token_hex)
+
+ def run_handler(script: str, cwd: str | None, env: Mapping[str, str] | None, timeout: float) -> JobResult:
+ del timeout
+ assert env is None
+ if cwd is None:
+ return _job_result("mkdir-job", status=JobStatusName.EXITED, done=True, exit_code=0)
+
+ assert cwd == "~/workspace/abc12ff"
+ assert "export PROJECT_NAME='demo project'" in script
+ assert 'export OPENAI_API_KEY="${OPENAI_API_KEY:-}"' in script
+ assert "export DIFY_SANDBOX_PROVIDER='independent'" in script
+ assert "export DIFY_SANDBOX_CONFIG_JSON='{\"cpu\": 2}'" in script
+ assert script.endswith("\npwd")
+ return _job_result("user-job", status=JobStatusName.EXITED, done=True, exit_code=0)
+
+ client = FakeShellctlClient(run_handler=run_handler)
+ layer = _shell_layer(
+ client_factory=lambda _entrypoint: client,
+ config=DifyShellLayerConfig(
+ env=[DifyShellEnvVarConfig(name="PROJECT_NAME", value="demo project")],
+ secret_refs=[DifyShellSecretRefConfig(name="OPENAI_API_KEY", ref="secret-1")],
+ sandbox=DifyShellSandboxConfig(provider="independent", config={"cpu": 2}),
+ ),
+ )
+ tools = {tool.name: tool for tool in layer.tools}
+
+ async def scenario() -> None:
+ async with layer.resource_context():
+ await layer.on_context_create()
+ run_result = cast(
+ Mapping[str, object],
+ await tools["shell_run"].function_schema.call(
+ {"script": "pwd"},
+ None, # pyright: ignore[reportArgumentType]
+ ),
+ )
+ assert run_result["job_id"] == "user-job"
+
+ asyncio.run(scenario())
+
+ assert [call.cwd for call in client.run_calls] == [None, "~/workspace/abc12ff"]
+ assert layer.runtime_state.job_ids == ["mkdir-job", "user-job"]
+
+
def test_shell_layer_tools_map_inputs_to_shellctl_calls_and_maintain_offsets() -> None:
def run_handler(script: str, cwd: str | None, env: Mapping[str, str] | None, timeout: float) -> JobResult:
- assert script.endswith("\npwd")
- assert '. ".dify/env.sh"' in script
+ assert script == "pwd"
assert cwd == "~/workspace/abc12ff"
assert env is None
assert timeout == 2.5
@@ -608,8 +658,7 @@ def test_shell_layer_tools_map_inputs_to_shellctl_calls_and_maintain_offsets() -
def test_shell_layer_injects_agent_stub_env_only_for_user_visible_shell_run() -> None:
def run_handler(script: str, cwd: str | None, env: Mapping[str, str] | None, timeout: float) -> JobResult:
del cwd, timeout
- if script.endswith("\npwd"):
- assert '. ".dify/env.sh"' in script
+ if script == "pwd":
assert env is not None
return _job_result("user-job", status=JobStatusName.EXITED, done=True, exit_code=0)
assert env is None
@@ -639,8 +688,8 @@ def test_shell_layer_injects_agent_stub_env_only_for_user_visible_shell_run() ->
asyncio.run(scenario())
- user_run_call = next(call for call in client.run_calls if call.script.endswith("\npwd"))
- internal_run_calls = [call for call in client.run_calls if not call.script.endswith("\npwd")]
+ user_run_call = next(call for call in client.run_calls if call.script == "pwd")
+ internal_run_calls = [call for call in client.run_calls if call.script != "pwd"]
assert user_run_call.env == {
AGENT_STUB_API_BASE_URL_ENV_VAR: "https://agent.example.com/agent-stub",
diff --git a/web/app/(commonLayout)/layout.tsx b/web/app/(commonLayout)/layout.tsx
index 6092618bfb3..71baf83c795 100644
--- a/web/app/(commonLayout)/layout.tsx
+++ b/web/app/(commonLayout)/layout.tsx
@@ -27,25 +27,26 @@ export default async function Layout({ children }: { children: ReactNode }) {
-
-
-
-
-
-
-
- {children}
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+ {children}
+
+
+
+
+
+
+
+
+
+
+
+
>
diff --git a/web/app/components/next-route-state/atoms.ts b/web/app/components/next-route-state/atoms.ts
index 1997f284976..0378415452c 100644
--- a/web/app/components/next-route-state/atoms.ts
+++ b/web/app/components/next-route-state/atoms.ts
@@ -7,6 +7,8 @@ type NextRouteState = {
params: NextRouteParams
}
+// Mirrors Next router state. NextRouteStateBridge force-hydrates this atom on
+// render so feature atoms can read route state without calling router hooks.
const nextRouteStateAtom = atom({
pathname: '',
params: {},
diff --git a/web/app/components/next-route-state/index.tsx b/web/app/components/next-route-state/index.tsx
index 7fb2d83c767..cc0bec4f4cb 100644
--- a/web/app/components/next-route-state/index.tsx
+++ b/web/app/components/next-route-state/index.tsx
@@ -1,24 +1,25 @@
'use client'
+import type { ReactNode } from 'react'
import type { NextRouteParams } from './atoms'
-import { useIsomorphicLayoutEffect } from 'foxact/use-isomorphic-layout-effect'
-import { useSetAtom } from 'jotai'
+import { useHydrateAtoms } from 'jotai/utils'
import { useParams, usePathname } from '@/next/navigation'
import {
setNextRouteStateAtom,
} from './atoms'
-export function NextRouteStateBridge() {
+export function NextRouteStateBridge({ children }: {
+ children: ReactNode
+}) {
const pathname = usePathname()
const params = useParams()
- const setNextRouteState = useSetAtom(setNextRouteStateAtom)
- useIsomorphicLayoutEffect(() => {
- setNextRouteState({
+ useHydrateAtoms([
+ [setNextRouteStateAtom, {
pathname,
params,
- })
- }, [params, pathname, setNextRouteState])
+ }],
+ ] as const, { dangerouslyForceHydrate: true })
- return null
+ return children
}
diff --git a/web/features/deployments/create-release/ui/dialog.tsx b/web/features/deployments/create-release/ui/dialog.tsx
index a0b6dde2fc6..9dd2731af6f 100644
--- a/web/features/deployments/create-release/ui/dialog.tsx
+++ b/web/features/deployments/create-release/ui/dialog.tsx
@@ -43,9 +43,11 @@ function CreateReleaseCloseButton() {
export function CreateReleaseDialogContent() {
return (
-
-
-
+
+
+
+
+
)
}
@@ -75,7 +77,7 @@ function CreateReleaseDialogSurface() {
}
return (
-
+ <>
-
+ >
)
}
diff --git a/web/features/deployments/list/index.tsx b/web/features/deployments/list/index.tsx
index af44215fe14..05eef13a39c 100644
--- a/web/features/deployments/list/index.tsx
+++ b/web/features/deployments/list/index.tsx
@@ -1,37 +1,8 @@
'use client'
-import type { ReactNode } from 'react'
-import { ScopeProvider } from 'jotai-scope'
-import { useQueryState } from 'nuqs'
-import {
- deploymentsListEnvironmentIdAtom,
- deploymentsListKeywordsAtom,
- envFilterQueryState,
- keywordsQueryState,
-} from './state'
+import { DeploymentsListStateBoundary } from './state'
import { DeploymentsListShell } from './ui/shell'
-function DeploymentsListStateBoundary({ children }: {
- children: ReactNode
-}) {
- const [envFilter] = useQueryState('env', envFilterQueryState)
- const [keywords] = useQueryState('keywords', keywordsQueryState)
- const stateKey = `${envFilter ?? 'all'}:${keywords}`
-
- return (
-
- {children}
-
- )
-}
-
export function DeploymentsList() {
return (
diff --git a/web/features/deployments/list/state/index.ts b/web/features/deployments/list/state/index.ts
index 0b8b91deae7..fd8bf674aa7 100644
--- a/web/features/deployments/list/state/index.ts
+++ b/web/features/deployments/list/state/index.ts
@@ -2,10 +2,12 @@
import type { ListAppInstanceSummariesResponse } from '@dify/contracts/enterprise/types.gen'
import type { InfiniteData, QueryKey } from '@tanstack/react-query'
+import type { ReactNode } from 'react'
import { keepPreviousData } from '@tanstack/react-query'
import { atom } from 'jotai'
import { atomWithInfiniteQuery, atomWithQuery } from 'jotai-tanstack-query'
-import { parseAsString } from 'nuqs'
+import { useHydrateAtoms } from 'jotai/utils'
+import { parseAsString, useQueryState } from 'nuqs'
import { consoleQuery } from '@/service/client'
import { getNextPageParamFromPagination, SOURCE_APPS_PAGE_SIZE } from '../../shared/domain/pagination'
import { deploymentStatusPollingInterval } from '../../shared/domain/runtime-status'
@@ -13,8 +15,24 @@ import { deploymentStatusPollingInterval } from '../../shared/domain/runtime-sta
export const envFilterQueryState = parseAsString.withOptions({ history: 'push' })
export const keywordsQueryState = parseAsString.withDefault('').withOptions({ history: 'push' })
-export const deploymentsListKeywordsAtom = atom('')
-export const deploymentsListEnvironmentIdAtom = atom(null)
+// Mirrors nuqs URL state. DeploymentsListStateBoundary force-hydrates these
+// atoms on render so query atoms can read URL filters through Jotai.
+const deploymentsListKeywordsAtom = atom('')
+const deploymentsListEnvironmentIdAtom = atom(null)
+
+export function DeploymentsListStateBoundary({ children }: {
+ children: ReactNode
+}) {
+ const [envFilter] = useQueryState('env', envFilterQueryState)
+ const [keywords] = useQueryState('keywords', keywordsQueryState)
+
+ useHydrateAtoms([
+ [deploymentsListEnvironmentIdAtom, envFilter],
+ [deploymentsListKeywordsAtom, keywords],
+ ] as const, { dangerouslyForceHydrate: true })
+
+ return children
+}
function listDeploymentStatusPollingInterval(data?: InfiniteData) {
const rows = data?.pages?.flatMap(page =>