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 =>