Merge branch 'main' into feat/refine-snippet-siderbar

This commit is contained in:
JzoNg 2026-06-23 20:37:36 +08:00
commit bd59fcf759
8 changed files with 143 additions and 112 deletions

View File

@ -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:

View File

@ -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",

View File

@ -27,25 +27,26 @@ export default async function Layout({ children }: { children: ReactNode }) {
<OAuthRegistrationAnalytics />
<EducationVerifyActionRecorder />
<CommonLayoutHydrationBoundary>
<AppContextProvider>
<EventEmitterContextProvider>
<ProviderContextProvider>
<ModalContextProvider>
<NextRouteStateBridge />
<MainNavLayout>
<RoleRouteGuard>
{children}
</RoleRouteGuard>
</MainNavLayout>
<InSiteMessageNotification />
<PartnerStack />
<ReadmePanel />
<GotoAnything />
<WorkflowGeneratorMount />
</ModalContextProvider>
</ProviderContextProvider>
</EventEmitterContextProvider>
</AppContextProvider>
<NextRouteStateBridge>
<AppContextProvider>
<EventEmitterContextProvider>
<ProviderContextProvider>
<ModalContextProvider>
<MainNavLayout>
<RoleRouteGuard>
{children}
</RoleRouteGuard>
</MainNavLayout>
<InSiteMessageNotification />
<PartnerStack />
<ReadmePanel />
<GotoAnything />
<WorkflowGeneratorMount />
</ModalContextProvider>
</ProviderContextProvider>
</EventEmitterContextProvider>
</AppContextProvider>
</NextRouteStateBridge>
</CommonLayoutHydrationBoundary>
<Zendesk />
</>

View File

@ -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<NextRouteState>({
pathname: '',
params: {},

View File

@ -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<NextRouteParams>()
const setNextRouteState = useSetAtom(setNextRouteStateAtom)
useIsomorphicLayoutEffect(() => {
setNextRouteState({
useHydrateAtoms([
[setNextRouteStateAtom, {
pathname,
params,
})
}, [params, pathname, setNextRouteState])
}],
] as const, { dangerouslyForceHydrate: true })
return null
return children
}

View File

@ -43,9 +43,11 @@ function CreateReleaseCloseButton() {
export function CreateReleaseDialogContent() {
return (
<ScopeProvider atoms={[createReleaseFormAtom]}>
<CreateReleaseDialogSurface />
</ScopeProvider>
<DialogContent className="top-[18dvh] w-140 max-w-[calc(100vw-32px)] translate-y-0 overflow-hidden p-0">
<ScopeProvider atoms={[createReleaseFormAtom]} name="CreateReleaseForm">
<CreateReleaseDialogSurface />
</ScopeProvider>
</DialogContent>
)
}
@ -75,7 +77,7 @@ function CreateReleaseDialogSurface() {
}
return (
<DialogContent className="top-[18dvh] w-140 max-w-[calc(100vw-32px)] translate-y-0 overflow-hidden p-0">
<>
<CreateReleaseCloseButton />
<form
noValidate
@ -105,6 +107,6 @@ function CreateReleaseDialogSurface() {
<CreateReleaseActions />
</form>
</DialogContent>
</>
)
}

View File

@ -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 (
<ScopeProvider
key={stateKey}
atoms={[
[deploymentsListEnvironmentIdAtom, envFilter],
[deploymentsListKeywordsAtom, keywords],
]}
name="DeploymentsList"
>
{children}
</ScopeProvider>
)
}
export function DeploymentsList() {
return (
<DeploymentsListStateBoundary>

View File

@ -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<string | null>(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<string | null>(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<ListAppInstanceSummariesResponse>) {
const rows = data?.pages?.flatMap(page =>