"""Shell runtime layer backed by a live shell provider resource. Shell command execution requires a bound execution-context layer with a safe ``agent_id``. The layer uses the current bound execution context to run commands with ``HOME=/`` and a home-rooted workspace path. The persisted runtime state intentionally keeps the historical ``~/workspace/`` identity so existing session snapshots stay compatible while live command execution no longer depends on the sandbox user's ambient home directory. Entering or re-entering the layer re-ensures the live home/workspace directories for the currently bound ``agent_id`` before user commands are sent. Sandbox lifecycle: The shell provider exposes four operations: ``create``, ``attach``, ``suspend``, and ``delete``. On the first run (no ``sandbox_id`` in runtime state) ``resource_context()`` calls ``create()`` to provision a new sandbox and persists the returned ``sandbox_id``. On subsequent runs it calls ``attach(sandbox_id)`` to re-connect to the existing sandbox. If the sandbox has expired (the provider raises ``SandboxExpiredError``), the error propagates to the caller — the user must start a new session. On normal exit (suspend) the resource is detached via ``suspend()``, keeping the sandbox alive. On final cleanup (``on_context_delete``) the resource is destroyed via ``delete()``. This allows the enterprise provider to reuse the same sandbox pod across conversation turns. """ from __future__ import annotations from collections.abc import AsyncGenerator, Sequence from contextlib import asynccontextmanager import json import logging import re import secrets import time from dataclasses import dataclass, field from typing import ClassVar, Literal, NotRequired, Protocol, TypedDict, runtime_checkable from pydantic import BaseModel, ConfigDict, Field, NonNegativeInt, field_validator, model_validator from pydantic_ai import Tool from typing_extensions import Self, override from agenton.layers import ( EmptyRuntimeState, LayerDeps, NoLayerDeps, PlainLayer, PydanticAILayer, PydanticAIPrompt, PydanticAITool, ) from dify_agent.adapters.shell.protocols import ( CompleteShellCommandResult, ShellCommandProtocol, ShellCommandResult, ShellPromptObservation, ShellProviderProtocol, ShellResourceProtocol, ) from dify_agent.agent_stub.protocol import AGENT_STUB_AUTH_JWE_ENV_VAR from dify_agent.agent_stub.shell_env import ShellAgentStubTokenFactory, build_shell_agent_stub_env from dify_agent.layers.execution_context import DifyExecutionContextLayerConfig from dify_agent.layers.shell.configs import DIFY_SHELL_LAYER_TYPE_ID, DifyShellLayerConfig from dify_agent.layers.shell.output_text import normalized_output_text, utf8_prefix, utf8_suffix logger = logging.getLogger(__name__) @runtime_checkable class _HasErrorCode(Protocol): code: object DEFAULT_TIMEOUT_SECONDS = 30.0 DEFAULT_TERMINATE_GRACE_SECONDS = 10.0 _WORKSPACE_ROOT = "~/workspace" _WORKSPACE_DIR_NAME = "workspace" _WORKSPACE_COLLISION_EXIT_CODE = 17 _SESSION_TIME_HEX_MASK = 0xFFFFF _SESSION_RANDOM_HEX_LENGTH = 2 _SESSION_ID_ATTEMPT_LIMIT = 256 _SESSION_ID_PATTERN = re.compile(r"^[0-9a-f]{7}$") _AGENT_HOME_SEGMENT_PATTERN = re.compile(r"^[A-Za-z0-9._-]+$") _SHELL_OUTPUT_PROMPT_EDGE_BYTES = 8 * 1024 _SHELLCTL_OUTPUT_LIMIT_BYTES = 2 * _SHELL_OUTPUT_PROMPT_EDGE_BYTES _REMOTE_COMPLETE_OUTPUT_MAX_BYTES = 1024 * 1024 _REMOTE_COMMAND_TIMEOUT_SECONDS = 60.0 _SHELL_LAYER_PREFIX_PROMPT = """You can run commands in an isolated shell workspace. Available shell tools: 1. shell_run Starts a new shell job in the current workspace. Use it to run commands or scripts. 2. shell_wait Waits for more output or completion from an existing shell job. Use it when shell_run returns done=false. 3. shell_input Sends stdin text to a running shell job, then waits for new output. Use it only when an interactive command is waiting for input. 4. shell_interrupt Interrupts a running shell job. Use it to stop a long-running, stuck, or no-longer-needed command. Common arguments: - script: Command or script to execute. Used by shell_run. - job_id: Shell job id returned by shell_run. Use it with shell_wait, shell_input, and shell_interrupt. Never invent a job_id. - timeout: Maximum time in seconds to wait for output or completion for this tool call. A timeout does not necessarily mean the job has stopped; if done=false, use shell_wait again. - text: Text to send to the running process stdin. Used by shell_input. Include "\\n" if the process expects Enter. - grace_seconds: Time to wait after interrupting before forceful cleanup. Used by shell_interrupt. Usage rules: - Start with shell_run. - If shell_run returns done=false, call shell_wait with the returned job_id. - Use shell_input only when the job is running and waiting for stdin. - Use shell_interrupt when a job is stuck or should be stopped. Installed CLI: - `dify-agent` is already installed in this shell environment and can be used directly. - Use the generated `dify-agent ... --help` output in the config prompt for exact command syntax. - Do not install or recreate the `dify-agent` CLI. Workspace persistence rules: - The current workspace cwd is stable during this run, but it is temporary and may be deleted later. - Do not treat files in the current workspace cwd as persisted state. - In build mode, config changes persist only after you run the matching `dify-agent config ...` mutation command. - Shell file edits alone do not save Agent config files, skills, env, or notes. - In non-build modes, local shell changes are not a persistence mechanism for Agent configuration. shell_run script rules: - The script argument can be a normal shell script or a shebang script. - If the first line is a shebang, the shell executes the script directly. Tips: - Python 3.12, uv, pip, Node.js, pnpm, and pnx are preinstalled in the local sandbox. - For one-off Python dependencies, prefer a uv script with a PEP 723 dependency header or: `uv run --with python `. - For reusable Python CLI tools, use `uv tool install `; installed commands land in `$HOME/.local/bin`. Run them by full path or add `$HOME/.local/bin` to PATH in the command that needs them. - `python3 -m pip install --user ` also installs into `$HOME/.local`; add `$HOME/.local/bin` to PATH when you need console scripts. - For reusable Node.js CLIs, use user-level global installs: `PNPM_HOME=$HOME/.local/share/pnpm PATH=$HOME/.local/share/pnpm/bin:$PATH pnpm add -g `. Installed commands land in `$PNPM_HOME/bin`; run them by full path or with the same PATH prefix. - For one-off Node.js CLIs, prefer `pnx [args]`. - Do not install new packages into system or image tool paths such as `/usr/local`, `/usr`, or `/opt/dify-agent-tools`. - If you need MCP, install the MCP server in the shell environment and start that server when you use it. Example shell_run script: [begin script] #!/usr/bin/env -S uv run --quiet --script # /// script # requires-python = ">=3.12" # dependencies = [ # "httpx==0.28.1", # "rich>=13.8.0", # ] # /// import httpx from rich import print response = httpx.get("https://example.com", timeout=10) print(f"[green]status:[/green] {response.status_code}") [end script]""" _SHELL_LAYER_SUFFIX_PROMPT = """Environment variables may contain API keys, tokens, or credentials. You may refer to environment variable names when needed.""" class ShellToolErrorObservation(TypedDict): error: str job_id: NotRequired[str] type ShellRunToolResult = str | ShellToolErrorObservation type ShellInterruptToolResult = str | ShellToolErrorObservation class DifyShellLayerDeps(LayerDeps): execution_context: PlainLayer[NoLayerDeps, DifyExecutionContextLayerConfig, EmptyRuntimeState] | None # pyright: ignore[reportUninitializedInstanceVariable] class DifyShellRuntimeState(BaseModel): session_id: str | None = None workspace_cwd: str | None = None sandbox_id: str | None = None job_ids: list[str] = Field(default_factory=list) job_offsets: dict[str, NonNegativeInt] = Field(default_factory=dict) model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid", validate_assignment=True) @field_validator("session_id") @classmethod def validate_session_id(cls, value: str | None) -> str | None: if value is None: return value return _validated_session_id(value) @field_validator("job_ids") @classmethod def validate_job_ids(cls, value: list[str]) -> list[str]: if len(value) != len(set(value)): raise ValueError("job_ids must not contain duplicates.") return value @model_validator(mode="after") def validate_workspace_and_offsets(self) -> Self: if self.workspace_cwd is not None: if self.session_id is None: raise ValueError("workspace_cwd requires a matching session_id.") expected_workspace = _workspace_cwd(self.session_id) if self.workspace_cwd != expected_workspace: raise ValueError(f"workspace_cwd must equal {expected_workspace!r} for session_id {self.session_id!r}.") unknown_offset_job_ids = set(self.job_offsets) - set(self.job_ids) if unknown_offset_job_ids: names = ", ".join(sorted(unknown_offset_job_ids)) raise ValueError(f"job_offsets contains unknown job ids: {names}.") return self CompleteRemoteCommandResult = CompleteShellCommandResult @dataclass(slots=True) class DifyShellLayer(PydanticAILayer[DifyShellLayerDeps, object, DifyShellLayerConfig, DifyShellRuntimeState]): type_id: ClassVar[str | None] = DIFY_SHELL_LAYER_TYPE_ID config: DifyShellLayerConfig shell_provider: ShellProviderProtocol shell_home_root: str = "/home" shell_redact_patterns: list[str] = field(default_factory=list) agent_stub_api_base_url: str | None = None agent_stub_token_factory: ShellAgentStubTokenFactory | None = None _shell_resource: ShellResourceProtocol | None = None _resource_should_delete: bool = False @classmethod @override def from_config(cls, config: DifyShellLayerConfig) -> Self: del config raise TypeError("DifyShellLayer requires a shell provider and must use a provider factory.") @classmethod def from_config_with_settings( cls, config: DifyShellLayerConfig, *, shell_provider: ShellProviderProtocol | None, shell_home_root: str = "/home", shell_redact_patterns: list[str] | None = None, agent_stub_api_base_url: str | None = None, agent_stub_token_factory: ShellAgentStubTokenFactory | None = None, ) -> Self: if shell_provider is None: raise ValueError("DifyShellLayer requires a non-null shell provider when the 'dify.shell' layer is used.") layer = cls( config=config, shell_provider=shell_provider, shell_home_root=_normalize_shell_home_root(shell_home_root), shell_redact_patterns=shell_redact_patterns or [], agent_stub_api_base_url=agent_stub_api_base_url, agent_stub_token_factory=agent_stub_token_factory, ) layer.bind_deps({}) return layer @property @override def prefix_prompts(self) -> Sequence[PydanticAIPrompt[object]]: return [_shell_layer_prefix_prompt] @property @override def suffix_prompts(self) -> Sequence[PydanticAIPrompt[object]]: return [_shell_layer_suffix_prompt] @property @override def tools(self) -> Sequence[PydanticAITool[object]]: return [ Tool(self._tool_run, name="shell_run"), Tool(self._tool_wait, name="shell_wait"), Tool(self._tool_input, name="shell_input"), Tool(self._tool_interrupt, name="shell_interrupt"), ] @override @asynccontextmanager async def resource_context(self) -> AsyncGenerator[None]: """Acquire the live shell resource for one run invocation. On the first run (no ``sandbox_id`` in runtime state) the provider's ``create()`` is called to provision a new sandbox. On subsequent runs ``attach(sandbox_id)`` re-connects to the existing sandbox. If the sandbox has expired (``SandboxExpiredError``), the error propagates to the caller — the user must start a new session. On exit, ``suspend()`` is called by default to keep the sandbox alive. If ``on_context_delete()`` ran (setting ``_resource_should_delete``), ``delete()`` is called instead to destroy the sandbox. """ if self._shell_resource is not None: raise RuntimeError("DifyShellLayer resource_context() is already active for this layer instance.") sandbox_id = self.runtime_state.sandbox_id if sandbox_id is not None: resource = await self.shell_provider.attach(sandbox_id) else: resource = await self.shell_provider.create() self.runtime_state = DifyShellRuntimeState.model_validate( { **self.runtime_state.model_dump(mode="python"), "sandbox_id": resource.sandbox_id, } ) self._shell_resource = resource self._resource_should_delete = False try: yield finally: self._shell_resource = None if self._resource_should_delete: await resource.delete() else: await resource.suspend() @override async def on_context_create(self) -> None: _ = self._require_resource() session_id: str | None = None try: session_id, workspace_cwd = await self._allocate_workspace() await self._bootstrap_workspace(session_id) except BaseException: if session_id is not None: await self._cleanup_workspace_best_effort(session_id) self._resource_should_delete = True raise self.runtime_state = DifyShellRuntimeState.model_validate( { **self.runtime_state.model_dump(mode="python"), "session_id": session_id, "workspace_cwd": workspace_cwd, } ) @override async def on_context_resume(self) -> None: _ = self._require_resource() session_id, _workspace_cwd = self._require_session_identity() await self._ensure_live_workspace_exists(session_id) @override async def on_context_suspend(self) -> None: _ = self._require_resource() @override async def on_context_delete(self) -> None: _ = self._require_resource() identity = self._try_session_identity() if identity is not None: session_id, _workspace_cwd = identity result = await self._run_internal_script_complete( _workspace_cleanup_script(session_id=session_id), cwd=None ) if result.exit_code != 0 or not result.output_complete: logger.warning( "Shell workspace cleanup for session %s ended with status=%s exit_code=%s output_complete=%s.", session_id, result.status, result.exit_code, result.output_complete, ) await self._delete_tracked_jobs_best_effort(self.runtime_state.job_ids) self._clear_tracked_jobs() self._resource_should_delete = True async def _tool_run(self, script: str, timeout: float = DEFAULT_TIMEOUT_SECONDS) -> ShellRunToolResult: try: result = await self._require_resource().commands.run( _wrap_user_script(script, self.config), cwd=self._require_workspace_cwd(), env=self._build_shell_command_env(include_agent_stub_env=True), timeout=timeout, ) observation = await render_prompt_observation_from_result( self._require_resource().commands, result, edge_bytes=_SHELL_OUTPUT_PROMPT_EDGE_BYTES, ) self._remember_job_id(result.job_id) self._remember_job_offset(result.job_id, observation.offset) return _tagged_shell_observation( _metadata_dict( job_id=result.job_id, status=result.status, done=result.done, exit_code=result.exit_code, output_path=observation.output_path, ), self._redact_output(observation.text), ) except (RuntimeError, ValueError) as exc: return _tool_error_from_exception(exc) except Exception as exc: return _tool_unexpected_error("shell_run", exc, session_id=self.runtime_state.session_id) async def _tool_wait(self, job_id: str, timeout: float = DEFAULT_TIMEOUT_SECONDS) -> ShellRunToolResult: try: offset = self._tracked_offset(job_id) result = await self._require_resource().commands.wait(job_id, offset=offset, timeout=timeout) observation = await render_prompt_observation_from_result( self._require_resource().commands, result, edge_bytes=_SHELL_OUTPUT_PROMPT_EDGE_BYTES, ) self._remember_job_id(result.job_id) self._remember_job_offset(result.job_id, observation.offset) return _tagged_shell_observation( _metadata_dict( job_id=result.job_id, status=result.status, done=result.done, exit_code=result.exit_code, output_path=observation.output_path, ), self._redact_output(observation.text), ) except (RuntimeError, ValueError) as exc: return _tool_error_from_exception(exc, job_id=job_id) except Exception as exc: return _tool_unexpected_error("shell_wait", exc, session_id=self.runtime_state.session_id, job_id=job_id) async def _tool_input(self, job_id: str, text: str, timeout: float = DEFAULT_TIMEOUT_SECONDS) -> ShellRunToolResult: try: offset = self._tracked_offset(job_id) result = await self._require_resource().commands.input(job_id, text, offset=offset, timeout=timeout) observation = await render_prompt_observation_from_result( self._require_resource().commands, result, edge_bytes=_SHELL_OUTPUT_PROMPT_EDGE_BYTES, ) self._remember_job_id(result.job_id) self._remember_job_offset(result.job_id, observation.offset) return _tagged_shell_observation( _metadata_dict( job_id=result.job_id, status=result.status, done=result.done, exit_code=result.exit_code, output_path=observation.output_path, ), self._redact_output(observation.text), ) except (RuntimeError, ValueError) as exc: return _tool_error_from_exception(exc, job_id=job_id) except Exception as exc: return _tool_unexpected_error("shell_input", exc, session_id=self.runtime_state.session_id, job_id=job_id) async def _tool_interrupt( self, job_id: str, grace_seconds: float = DEFAULT_TERMINATE_GRACE_SECONDS, ) -> ShellInterruptToolResult: try: self._ensure_tracked_job(job_id) result = await self._require_resource().commands.interrupt(job_id, grace_seconds=grace_seconds) self._remember_job_id(result.job_id) self._remember_job_offset(result.job_id, result.offset) output_path: str | None = None try: # Once the interrupt itself succeeds, resolving the output path is # best-effort metadata enrichment and must not turn the interrupt # into a failed tool result. output_path = (await self._require_resource().commands.tail(job_id)).output_path except (RuntimeError, ValueError) as exc: logger.warning( "Failed to fetch output path for interrupted shell job %s in session %s: %s", job_id, self.runtime_state.session_id, exc, ) except Exception: logger.exception( "Failed to fetch output path for interrupted shell job %s in session %s", job_id, self.runtime_state.session_id, ) return _tagged_shell_observation( _metadata_dict( job_id=result.job_id, status=result.status, done=result.done, exit_code=result.exit_code, output_path=output_path, ), "Job was interrupted.", ) except (RuntimeError, ValueError) as exc: return _tool_error_from_exception(exc, job_id=job_id) except Exception as exc: return _tool_unexpected_error( "shell_interrupt", exc, session_id=self.runtime_state.session_id, job_id=job_id ) async def run_remote_script_complete( self, script: str, *, timeout: float = _REMOTE_COMMAND_TIMEOUT_SECONDS, max_output_bytes: int = _REMOTE_COMPLETE_OUTPUT_MAX_BYTES, inject_agent_stub_env: bool = False, ) -> CompleteRemoteCommandResult: return await execute_complete_with_commands( self._require_resource().commands, script, cwd=self._require_workspace_cwd(), env=self._build_shell_command_env( include_agent_stub_env=inject_agent_stub_env, require_agent_stub_env=inject_agent_stub_env, ), timeout=timeout, max_output_bytes=max_output_bytes, ) async def run_remote_script( self, script: str, *, timeout: float = _REMOTE_COMMAND_TIMEOUT_SECONDS, inject_agent_stub_env: bool = False, ) -> CompleteRemoteCommandResult: return await self.run_remote_script_complete( script, timeout=timeout, inject_agent_stub_env=inject_agent_stub_env, ) async def _allocate_workspace(self) -> tuple[str, str]: for _attempt in range(_SESSION_ID_ATTEMPT_LIMIT): session_id = _generate_session_id() result = await self._run_internal_script_complete(_workspace_mkdir_script(session_id=session_id), cwd=None) if result.exit_code == _WORKSPACE_COLLISION_EXIT_CODE: continue if result.exit_code != 0 or not result.output_complete: raise RuntimeError( f"Failed to create shell workspace {_workspace_cwd(session_id)}: " + f"{result.status} exit_code={result.exit_code}" ) return session_id, _workspace_cwd(session_id) raise RuntimeError("Failed to allocate a unique shell workspace session id after 256 attempts.") async def _bootstrap_workspace(self, session_id: str) -> None: bootstrap_script = _workspace_bootstrap_script(self.config) if not bootstrap_script: return workspace_cwd = _workspace_cwd_for_home(self._shell_home_dir(), session_id) result = await self._run_internal_script_complete(bootstrap_script, cwd=workspace_cwd) if result.exit_code != 0 or not result.output_complete: raise RuntimeError( f"Failed to bootstrap shell workspace {workspace_cwd}: {result.status} exit_code={result.exit_code}" ) async def _cleanup_workspace_best_effort(self, session_id: str) -> None: try: _ = await self._run_internal_script_complete(_workspace_cleanup_script(session_id=session_id), cwd=None) except (RuntimeError, ValueError) as exc: logger.warning("Failed to remove shell workspace for session %s after create failure: %s", session_id, exc) async def _ensure_live_workspace_exists(self, session_id: str) -> None: result = await self._run_internal_script_complete(_workspace_ensure_script(session_id=session_id), cwd=None) if result.exit_code != 0 or not result.output_complete: raise RuntimeError( f"Failed to ensure shell workspace {_workspace_cwd_for_home(self._shell_home_dir(), session_id)} " + f"exists: {result.status} exit_code={result.exit_code}" ) async def _run_internal_script_complete( self, script: str, *, cwd: str | None, ) -> CompleteRemoteCommandResult: return await execute_complete_with_commands( self._require_resource().commands, script, cwd=cwd, env=self._build_shell_command_env(include_agent_stub_env=False), timeout=DEFAULT_TIMEOUT_SECONDS, max_output_bytes=_REMOTE_COMPLETE_OUTPUT_MAX_BYTES, ) def _require_resource(self) -> ShellResourceProtocol: if self._shell_resource is None: raise RuntimeError( "DifyShellLayer requires an active shell resource inside resource_context(); " + "enter the layer through Agenton or wrap direct hook/tool usage in resource_context()." ) return self._shell_resource def _require_workspace_cwd(self) -> str: session_id, _workspace_cwd = self._require_session_identity() return _workspace_cwd_for_home(self._shell_home_dir(), session_id) def _require_session_identity(self) -> tuple[str, str]: identity = self._try_session_identity() if identity is None: raise ValueError("DifyShellLayer runtime state is missing session_id or workspace_cwd.") session_id, workspace_cwd = identity expected_workspace = _workspace_cwd(session_id) if workspace_cwd != expected_workspace: raise ValueError( f"DifyShellLayer runtime state has inconsistent workspace_cwd {workspace_cwd!r}; expected {expected_workspace!r}." ) return session_id, workspace_cwd def _try_session_identity(self) -> tuple[str, str] | None: session_id = self.runtime_state.session_id workspace_cwd = self.runtime_state.workspace_cwd if session_id is None or workspace_cwd is None: return None return session_id, workspace_cwd def _ensure_tracked_job(self, job_id: str) -> None: if job_id not in self.runtime_state.job_ids: raise ValueError(f"Unknown shell job id for this session: {job_id}.") def _tracked_offset(self, job_id: str) -> int: self._ensure_tracked_job(job_id) return int(self.runtime_state.job_offsets.get(job_id, 0)) def _remember_job_id(self, job_id: str) -> None: if job_id in self.runtime_state.job_ids: return self.runtime_state.job_ids = [*self.runtime_state.job_ids, job_id] def _remember_job_offset(self, job_id: str, offset: int) -> None: job_offsets = dict(self.runtime_state.job_offsets) job_offsets[job_id] = offset self.runtime_state.job_offsets = job_offsets async def _delete_tracked_jobs_best_effort(self, job_ids: Sequence[str]) -> None: commands = self._require_resource().commands for job_id in _deduplicate_preserving_order(job_ids): try: await commands.delete(job_id, force=True) except RuntimeError as exc: logger.warning( "Failed to delete shell job %s for session %s: %s", job_id, self.runtime_state.session_id, exc, ) def _clear_tracked_jobs(self) -> None: self.runtime_state.job_offsets = {} self.runtime_state.job_ids = [] def _shell_home_dir(self) -> str: return _shell_home_dir_for_agent_id( self._require_current_execution_agent_id(), shell_home_root=self.shell_home_root, ) def _current_execution_agent_id(self) -> str | None: execution_context_layer = self.deps.execution_context execution_context = execution_context_layer.config if execution_context_layer is not None else None return execution_context.agent_id if execution_context is not None else None def _require_current_execution_agent_id(self) -> str: agent_id = self._current_execution_agent_id() if agent_id is None: raise ValueError("ShellLayer command execution requires execution_context.agent_id.") return _validated_agent_home_segment(agent_id) def _build_shell_command_env( self, *, include_agent_stub_env: bool, require_agent_stub_env: bool = False, ) -> dict[str, str]: env = _shell_config_env(self.config) env["HOME"] = self._shell_home_dir() if not include_agent_stub_env: return env execution_context_layer = self.deps.execution_context execution_context = execution_context_layer.config if execution_context_layer is not None else None agent_stub_env = build_shell_agent_stub_env( agent_stub_api_base_url=self.agent_stub_api_base_url, agent_stub_drive_ref=self.config.agent_stub_drive_ref, execution_context=execution_context, token_factory=self.agent_stub_token_factory, session_id=self.runtime_state.session_id, ) if agent_stub_env is None: if not require_agent_stub_env: return env raise RuntimeError("Agent Stub environment injection is not available for this shell session.") env.update(agent_stub_env) return env def _redact_output(self, text: str) -> str: """Redact sensitive content from shell output before the model sees it. Two layers of redaction are applied: 1. **Built-in token redaction** — the actual Agent Stub JWE token value is always replaced with ``***``. This is unconditional and cannot be disabled. 2. **Pattern redaction** — regex patterns from both server-level ``shell_redact_patterns`` and per-agent ``config.redact_patterns`` are applied via ``re.sub`` to mask additional secrets. """ if not text: return text # Built-in: always redact the JWE token value. env = self._build_shell_command_env(include_agent_stub_env=True) jwe_value = env.get(AGENT_STUB_AUTH_JWE_ENV_VAR) if jwe_value and len(jwe_value) > 8: text = text.replace(jwe_value, "***") # Server-level + per-agent regex patterns. for pattern in (*self.shell_redact_patterns, *self.config.redact_patterns): text = re.sub(pattern, "***", text) return text async def execute_complete_with_commands( commands: ShellCommandProtocol, script: str, *, cwd: str | None, env: dict[str, str] | None, timeout: float, max_output_bytes: int, ) -> CompleteShellCommandResult: deadline = time.monotonic() + timeout job_id: str | None = None result: ShellCommandResult | None = None output_parts: list[str] = [] captured_bytes = 0 incomplete_reason: Literal["output_limit", "timeout"] | None = None try: result = await commands.run(script, cwd=cwd, env=env, timeout=_remaining_time(deadline)) job_id = result.job_id while True: remaining_bytes = max(max_output_bytes - captured_bytes, 0) limited_output = utf8_prefix(result.output, remaining_bytes) output_parts.append(limited_output) captured_bytes += len(limited_output.encode("utf-8")) if limited_output != result.output: incomplete_reason = "output_limit" break if captured_bytes >= max_output_bytes and (result.truncated or not result.done): incomplete_reason = "output_limit" break if result.truncated: result = await commands.read_output(result.job_id, offset=result.offset) continue if result.done: break remaining_time = _remaining_time(deadline) if remaining_time <= 0.0: incomplete_reason = "timeout" break result = await commands.wait(result.job_id, offset=result.offset, timeout=remaining_time) assert result is not None final_status = result.status final_done = result.done final_exit_code = result.exit_code final_offset = result.offset final_output_path = result.output_path if incomplete_reason is not None and not result.done: terminal_status = await commands.interrupt(result.job_id, grace_seconds=DEFAULT_TERMINATE_GRACE_SECONDS) final_status = terminal_status.status final_done = terminal_status.done final_exit_code = terminal_status.exit_code final_offset = terminal_status.offset return CompleteShellCommandResult( job_id=result.job_id, status=final_status, done=final_done, exit_code=final_exit_code, output="".join(output_parts), output_complete=incomplete_reason is None, incomplete_reason=incomplete_reason, offset=final_offset, output_path=final_output_path, ) finally: if job_id is not None: try: await commands.delete(job_id, force=True) except RuntimeError as exc: logger.warning("Failed to delete transient shell job %s: %s", job_id, exc) async def render_prompt_observation_from_result( commands: ShellCommandProtocol, result: ShellCommandResult, *, edge_bytes: int, ) -> ShellPromptObservation: output_exceeds_edge_budget = len(result.output.encode("utf-8")) > edge_bytes tail: str | None = None output_path = result.output_path offset = result.offset if result.truncated: try: tail_result = await commands.tail(result.job_id) except RuntimeError as exc: logger.warning("Failed to fetch tail for shell job %s: %s", result.job_id, exc) else: tail = utf8_suffix(tail_result.output, edge_bytes) output_path = tail_result.output_path or output_path offset = tail_result.offset elif output_exceeds_edge_budget: tail = utf8_suffix(result.output, edge_bytes) text = normalized_output_text( utf8_prefix(result.output, edge_bytes), tail=tail, output_path=output_path if (result.truncated or output_exceeds_edge_budget) else None, max_output_size_bytes=_SHELLCTL_OUTPUT_LIMIT_BYTES, truncated_in_middle=result.truncated or output_exceeds_edge_budget, ) return ShellPromptObservation(text=text, output_path=output_path, offset=offset) def _shell_layer_prefix_prompt() -> str: return _SHELL_LAYER_PREFIX_PROMPT def _shell_layer_suffix_prompt() -> str: return _SHELL_LAYER_SUFFIX_PROMPT def _metadata_dict( *, job_id: str, status: str, done: bool, exit_code: int | None, output_path: str | None, ) -> dict[str, object]: metadata: dict[str, object] = { "job_id": job_id, "status": status, "done": done, "exit_code": exit_code, } if output_path is not None: metadata["output_path"] = output_path return metadata def _tool_error(message: str, *, job_id: str | None = None) -> ShellToolErrorObservation: result: ShellToolErrorObservation = {"error": message} if job_id is not None: result["job_id"] = job_id return result def _tool_error_from_exception(exc: Exception, *, job_id: str | None = None) -> ShellToolErrorObservation: # Expected provider/runtime failures stay inside the tool contract and are # returned to the model as observations. The broader Exception fallback # below handles unexpected failures; BaseException, including cancellation, # is intentionally left uncaught at the tool boundary. if isinstance(exc, _HasErrorCode) and isinstance(exc.code, str) and exc.code: return _tool_error(f"{exc.code}: {exc}", job_id=job_id) return _tool_error(str(exc), job_id=job_id) def _tool_unexpected_error( tool_name: str, exc: Exception, *, session_id: str | None, job_id: str | None = None, ) -> ShellToolErrorObservation: # Unexpected Exception still becomes a tool observation so one shell tool # failure does not abort the agent loop, but it is logged with traceback for # debugging. BaseException is intentionally not caught by callers. logger.exception( "Unexpected shell tool failure: tool=%s session_id=%s job_id=%s", tool_name, session_id, job_id, ) return _tool_error_from_exception(exc, job_id=job_id) def _generate_session_id() -> str: time_component = int(time.time()) & _SESSION_TIME_HEX_MASK random_component = secrets.token_hex(1) if len(random_component) != _SESSION_RANDOM_HEX_LENGTH: raise RuntimeError("Expected a one-byte random hex suffix for Dify shell session ids.") return f"{time_component:05x}{random_component}" def _workspace_cwd(session_id: str) -> str: return f"{_WORKSPACE_ROOT}/{_validated_session_id(session_id)}" def _normalize_shell_home_root(shell_home_root: str) -> str: stripped = shell_home_root.strip().rstrip("/") if not stripped: raise ValueError("shell_home_root must not be empty") if not stripped.startswith("/"): raise ValueError("shell_home_root must be an absolute path") return stripped def _shell_home_dir_for_agent_id(agent_id: str | None, *, shell_home_root: str = "/home") -> str: if agent_id is None: raise ValueError("ShellLayer command execution requires execution_context.agent_id.") return f"{_normalize_shell_home_root(shell_home_root)}/{_validated_agent_home_segment(agent_id)}" def _validated_agent_home_segment(agent_id: str) -> str: if agent_id in {".", ".."} or not _AGENT_HOME_SEGMENT_PATTERN.fullmatch(agent_id): raise ValueError("execution_context.agent_id must be a safe single path segment for shell HOME.") return agent_id def _workspace_cwd_for_home(home_dir: str, session_id: str) -> str: return f"{home_dir}/{_WORKSPACE_DIR_NAME}/{_validated_session_id(session_id)}" def _workspace_bootstrap_script(config: DifyShellLayerConfig) -> str: 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", *_shell_config_export_lines(config), *install_commands] return "\n".join(lines) def _shell_config_export_lines(config: DifyShellLayerConfig) -> list[str]: lines: list[str] = [] # Plain env values travel through ShellCommandProtocol.run(env=...) so inline # secrets are not rendered into generated shell scripts. for secret_ref in config.secret_refs: lines.append(f'export {secret_ref.name}="${{{secret_ref.name}:-}}"') for tool in config.cli_tools: for secret_ref in tool.secret_refs: lines.append(f'export {secret_ref.name}="${{{secret_ref.name}:-}}"') if config.sandbox is not None: if config.sandbox.provider: lines.append(f"export DIFY_SANDBOX_PROVIDER={_shquote(config.sandbox.provider)}") 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)}") return lines def _shell_config_env(config: DifyShellLayerConfig) -> dict[str, str]: env: dict[str, str] = {} for env_var in config.env: env[env_var.name] = env_var.value for tool in config.cli_tools: for env_var in tool.env: env[env_var.name] = env_var.value return env def _wrap_user_script(script: str, config: DifyShellLayerConfig) -> str: lines = _shell_config_export_lines(config) if not lines: return script return "\n".join([*lines, script]) def _workspace_mkdir_script(*, session_id: str) -> str: safe_session_id = _validated_session_id(session_id) workspace_dir = f"$HOME/workspace/{safe_session_id}" return ( 'mkdir -p "$HOME/workspace"; ' f'if mkdir "{workspace_dir}"; then exit 0; fi; ' f'if [ -e "{workspace_dir}" ]; then exit {_WORKSPACE_COLLISION_EXIT_CODE}; fi; ' "exit 1" ) def _workspace_cleanup_script(*, session_id: str) -> str: return f'rm -rf -- "$HOME/workspace/{_validated_session_id(session_id)}"' def _workspace_ensure_script(*, session_id: str) -> str: return f'mkdir -p "$HOME/workspace/{_validated_session_id(session_id)}"' def _shquote(value: str) -> str: return "'" + value.replace("'", "'\\''") + "'" def _validated_session_id(session_id: str) -> str: if not _SESSION_ID_PATTERN.fullmatch(session_id): raise ValueError("session_id must match the 5+2 lowercase hex format '<5 hex><2 hex>'.") return session_id def _deduplicate_preserving_order(values: Sequence[str]) -> list[str]: seen: set[str] = set() result: list[str] = [] for value in values: if value in seen: continue seen.add(value) result.append(value) return result def _tagged_shell_observation(metadata: dict[str, object], output: str) -> str: compact_metadata = json.dumps(metadata, separators=(",", ":")) return f"\n{compact_metadata}\n\n\n\n{output}\n" def _remaining_time(deadline: float) -> float: return max(0.0, deadline - time.monotonic()) __all__ = [ "CompleteRemoteCommandResult", "DifyShellLayer", "DifyShellLayerDeps", "DifyShellRuntimeState", "DEFAULT_TERMINATE_GRACE_SECONDS", "DEFAULT_TIMEOUT_SECONDS", "execute_complete_with_commands", "render_prompt_observation_from_result", ]