mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-03 07:11:56 +00:00
fix: remove code exec as default and sec improvements
This commit is contained in:
1 parent
2d79ff2227
commit
66786c2760
41 files changed
+800
-772
No files matched your search
@@ -107,9 +107,9 @@ Untrusted input includes API payloads, file uploads, remote URLs, OAuth/webhook
|
||||
|
||||
### I. Sandboxed code execution and tenant isolation
|
||||
- Threat: LLM-authored code (the code-execution tool, document/artifact generation, and workflow code nodes) runs attacker-influenceable Python; a poisoned document or prompt can shape what executes.
|
||||
- Threat: on the self-hosted Jupyter Kernel Gateway runner, all sessions run as kernels inside one shared container and uid — a kernel can read sibling sessions' workspaces and reach the network. Treat a single runner as one trust domain, not a per-tenant boundary.
|
||||
- Threat: `code_executor` is on by default and runs sandboxed code WITHOUT an approval prompt, so any chat agent (including one steered by prompt injection) can execute code unprompted; the sandbox is the only boundary.
|
||||
- Mitigations: code-exec approval exists per tool but is off for the default tool (enable it or rely on the sandbox boundary); pass workflow state to code nodes as data (a `state.json` file), never templated into the executed program; scrub secrets from the kernel environment (no provider keys/tokens/DB URL reach kernel code); path-traversal-safe file I/O with output/time/size caps and per-session `0700` workspaces; block egress at the network layer (NetworkPolicy/host firewall). For per-tenant isolation use the Daytona per-session-VM backend (`SANDBOX_BACKEND=daytona`); run the self-hosted runner under gVisor for host protection. Artifacts are access-controlled by their parent (conversation or workflow run).
|
||||
- Threat: on the self-hosted Jupyter Kernel Gateway runner, all sessions run as kernels inside one shared container and uid — a kernel can read sibling sessions' workspaces and reach the network. Treat a single runner as one trust domain, not a per-tenant boundary. The gateway's control API is reachable from kernel code over loopback, so it is authenticated (a required, env-scrubbed token) to stop a kernel from driving sibling kernels or bypassing the session cap.
|
||||
- Threat: an agent with `code_executor` / `artifact_generator` enabled runs sandboxed code that a poisoned document or prompt can shape; a prompt-injected agent can execute code within the sandbox boundary. Both tools are opt-in (off by default, not in `DEFAULT_CHAT_TOOLS`, and gated behind a per-agent enable plus a running runner), which limits exposure to agents an operator deliberately configured for code execution.
|
||||
- Mitigations: code-exec approval is available per tool; the runner and both tools are opt-in (a fresh deploy runs no sandbox); the gateway requires an auth token (fails closed) and scrubs it plus all secrets from the kernel environment; pass workflow state to code nodes as data (a `state.json` file), never templated into the executed program; path-traversal-safe file I/O with output/time/size caps and per-session `0700` workspaces; block egress at the network layer (NetworkPolicy/host firewall). For per-tenant isolation use the Daytona per-session-VM backend (`SANDBOX_BACKEND=daytona`); run the self-hosted runner under gVisor for host protection. Artifacts are access-controlled by their parent (conversation or workflow run).
|
||||
|
||||
## 8) Example attacker stories
|
||||
|
||||
|
||||
@@ -31,11 +31,16 @@ _HEADLESS_EXCLUDED_TOOLS = frozenset({"scheduler"})
|
||||
# default tools. Names may overlap with DEFAULT_CHAT_TOOLS (e.g. ``scheduler``)
|
||||
# — both registries share ``_DEFAULT_TOOL_NAMESPACE`` so the same uuid5
|
||||
# resolves either way (the dual-flag row carries ``default`` AND ``builtin``).
|
||||
# ``code_executor`` is builtin-only (not default-on): it needs a running sandbox
|
||||
# runner so it is opt-in per agent, but staying registered keeps its synthetic id
|
||||
# resolvable (an agent that enabled it never silently loses it) and keeps it in the
|
||||
# agent picker.
|
||||
BUILTIN_AGENT_TOOLS: tuple = ("scheduler", "read_document", "code_executor")
|
||||
# ``code_executor`` and ``artifact_generator`` are builtin-only (not default-on):
|
||||
# both render/execute through a running sandbox runner, so they are opt-in per
|
||||
# agent, but staying registered keeps their synthetic ids resolvable (an agent
|
||||
# that enabled one never silently loses it) and keeps them in the agent picker.
|
||||
BUILTIN_AGENT_TOOLS: tuple = (
|
||||
"scheduler",
|
||||
"read_document",
|
||||
"code_executor",
|
||||
"artifact_generator",
|
||||
)
|
||||
|
||||
# Builtins shown only in the workflow-node tool picker, never the classic
|
||||
# agent picker. The synthesized row carries ``workflow_only`` so the frontend
|
||||
|
||||
@@ -85,11 +85,7 @@ def _record_proposed(
|
||||
tool_id=tool_id if tool_id and looks_like_uuid(tool_id) else None,
|
||||
message_id=message_id,
|
||||
user_id=user_id,
|
||||
agent_id=(
|
||||
str(agent_id)
|
||||
if agent_id and looks_like_uuid(str(agent_id))
|
||||
else None
|
||||
),
|
||||
agent_id=(str(agent_id) if agent_id and looks_like_uuid(str(agent_id)) else None),
|
||||
)
|
||||
if not inserted:
|
||||
logger.warning(
|
||||
@@ -147,11 +143,7 @@ def _mark_executed(
|
||||
message_id=message_id,
|
||||
artifact_id=artifact_id,
|
||||
user_id=user_id,
|
||||
agent_id=(
|
||||
str(agent_id)
|
||||
if agent_id and looks_like_uuid(str(agent_id))
|
||||
else None
|
||||
),
|
||||
agent_id=(str(agent_id) if agent_id and looks_like_uuid(str(agent_id)) else None),
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("tool_call_attempts executed write failed for %s", call_id)
|
||||
@@ -160,9 +152,7 @@ def _mark_executed(
|
||||
def _mark_failed(call_id: str, error: str, *, user_id: Optional[str] = None) -> None:
|
||||
try:
|
||||
with db_session() as conn:
|
||||
ToolCallAttemptsRepository(conn).mark_failed(
|
||||
call_id, error, user_id=user_id
|
||||
)
|
||||
ToolCallAttemptsRepository(conn).mark_failed(call_id, error, user_id=user_id)
|
||||
except Exception:
|
||||
logger.exception("tool_call_attempts failed-write failed for %s", call_id)
|
||||
|
||||
@@ -191,9 +181,7 @@ class ToolExecutor:
|
||||
# so check_pause returns headless_denied sentinels instead.
|
||||
self.headless = bool(headless)
|
||||
# Tool-instance ids pre-authorized for headless approval-gated execution.
|
||||
self.tool_allowlist: set = (
|
||||
{str(x) for x in tool_allowlist} if tool_allowlist else set()
|
||||
)
|
||||
self.tool_allowlist: set = {str(x) for x in tool_allowlist} if tool_allowlist else set()
|
||||
self.tool_calls: List[Dict] = []
|
||||
self._loaded_tools: Dict[str, object] = {}
|
||||
self.conversation_id: Optional[str] = None
|
||||
@@ -234,11 +222,7 @@ class ToolExecutor:
|
||||
tools plus the synthesized defaults. Used to gate tool-specific prompt
|
||||
sections via the ``tools.enabled`` template namespace.
|
||||
"""
|
||||
return {
|
||||
str(tool["name"])
|
||||
for tool in self.get_tools().values()
|
||||
if isinstance(tool, dict) and tool.get("name")
|
||||
}
|
||||
return {str(tool["name"]) for tool in self.get_tools().values() if isinstance(tool, dict) and tool.get("name")}
|
||||
|
||||
def _get_tools_by_api_key(self, api_key: str) -> Dict[str, Dict]:
|
||||
"""Resolve an agent's toolset — exactly ``agents.tools``, no defaults."""
|
||||
@@ -249,11 +233,7 @@ class ToolExecutor:
|
||||
agent_data = AgentsRepository(conn).find_by_key(api_key)
|
||||
tool_ids = agent_data.get("tools", []) if agent_data else []
|
||||
tools_repo = UserToolsRepository(conn)
|
||||
owner = (
|
||||
(agent_data.get("user_id") or agent_data.get("user"))
|
||||
if agent_data
|
||||
else None
|
||||
)
|
||||
owner = (agent_data.get("user_id") or agent_data.get("user")) if agent_data else None
|
||||
tools: List[Dict] = []
|
||||
for tid in tool_ids:
|
||||
row = resolve_tool_by_id(tid, owner, user_tools_repo=tools_repo)
|
||||
@@ -270,29 +250,23 @@ class ToolExecutor:
|
||||
"""Resolve an agentless chat's toolset: explicit user tools plus defaults."""
|
||||
with db_readonly() as conn:
|
||||
user_tools = UserToolsRepository(conn).list_active_for_user(user)
|
||||
user_doc = (
|
||||
UsersRepository(conn).get(user) if self.agent_id is None else None
|
||||
)
|
||||
user_doc = UsersRepository(conn).get(user) if self.agent_id is None else None
|
||||
# Headless agentless runs (e.g. scheduled fire) drop chat-only
|
||||
# tools (``scheduler``) from explicit user_tools too.
|
||||
filtered_user_tools = [
|
||||
t for t in user_tools
|
||||
if not (self.headless and is_headless_excluded_tool(t.get("name")))
|
||||
t for t in user_tools if not (self.headless and is_headless_excluded_tool(t.get("name")))
|
||||
]
|
||||
# Index keys (ints) and synthetic uuid5 keys can't collide.
|
||||
tools: Dict[str, Dict] = {
|
||||
str(i): tool for i, tool in enumerate(filtered_user_tools)
|
||||
}
|
||||
tools: Dict[str, Dict] = {str(i): tool for i, tool in enumerate(filtered_user_tools)}
|
||||
if self.agent_id is None:
|
||||
for default_row in synthesized_default_tools(
|
||||
user_doc, headless=self.headless,
|
||||
user_doc,
|
||||
headless=self.headless,
|
||||
):
|
||||
tools[str(default_row["id"])] = default_row
|
||||
return tools
|
||||
|
||||
def merge_client_tools(
|
||||
self, tools_dict: Dict, client_tools: List[Dict]
|
||||
) -> Dict:
|
||||
def merge_client_tools(self, tools_dict: Dict, client_tools: List[Dict]) -> Dict:
|
||||
"""Merge client-provided tool definitions into tools_dict.
|
||||
|
||||
Client tools use the standard function-calling format::
|
||||
@@ -358,18 +332,12 @@ class ToolExecutor:
|
||||
if not is_api and "actions" not in tool:
|
||||
continue
|
||||
|
||||
actions = (
|
||||
tool["config"]["actions"].values()
|
||||
if is_api
|
||||
else tool["actions"]
|
||||
)
|
||||
actions = tool["config"]["actions"].values() if is_api else tool["actions"]
|
||||
|
||||
for action in actions:
|
||||
if not action.get("active", True):
|
||||
continue
|
||||
entries.append(
|
||||
(tool_id, tool.get("name", ""), action["name"], action, is_client)
|
||||
)
|
||||
entries.append((tool_id, tool.get("name", ""), action["name"], action, is_client))
|
||||
name_counts[action["name"]] += 1
|
||||
|
||||
# Pass 2: assign LLM-visible names and build mappings
|
||||
@@ -379,34 +347,19 @@ class ToolExecutor:
|
||||
|
||||
result = []
|
||||
for tool_id, tool_name, action_name, action, is_client in entries:
|
||||
if (
|
||||
name_counts[action_name] == 1
|
||||
and len(action_name) <= _MAX_LLM_NAME_LEN
|
||||
):
|
||||
if name_counts[action_name] == 1 and len(action_name) <= _MAX_LLM_NAME_LEN:
|
||||
llm_name = action_name
|
||||
else:
|
||||
# An over-long unique name skips the prefix — it needs
|
||||
# truncation, not disambiguation.
|
||||
prefix = (
|
||||
_sanitize_tool_prefix(tool_name)
|
||||
if name_counts[action_name] > 1
|
||||
else ""
|
||||
)
|
||||
base = (
|
||||
f"{prefix}_{action_name}"
|
||||
if prefix and not action_name.startswith(f"{prefix}_")
|
||||
else action_name
|
||||
)
|
||||
prefix = _sanitize_tool_prefix(tool_name) if name_counts[action_name] > 1 else ""
|
||||
base = f"{prefix}_{action_name}" if prefix and not action_name.startswith(f"{prefix}_") else action_name
|
||||
base = base[:_MAX_LLM_NAME_LEN]
|
||||
# A duplicated bare name stays ambiguous, and a candidate
|
||||
# must not steal a unique action's name or one already taken.
|
||||
candidate = base
|
||||
counter = 1
|
||||
while (
|
||||
candidate == action_name
|
||||
or candidate in all_llm_names
|
||||
or name_counts.get(candidate, 0) == 1
|
||||
):
|
||||
while candidate == action_name or candidate in all_llm_names or name_counts.get(candidate, 0) == 1:
|
||||
suffix = f"_{counter}"
|
||||
candidate = base[: _MAX_LLM_NAME_LEN - len(suffix)] + suffix
|
||||
counter += 1
|
||||
@@ -421,14 +374,16 @@ class ToolExecutor:
|
||||
else:
|
||||
params = self._build_tool_parameters(action)
|
||||
|
||||
result.append({
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": llm_name,
|
||||
"description": action.get("description", ""),
|
||||
"parameters": params,
|
||||
},
|
||||
})
|
||||
result.append(
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": llm_name,
|
||||
"description": action.get("description", ""),
|
||||
"parameters": params,
|
||||
},
|
||||
}
|
||||
)
|
||||
return result
|
||||
|
||||
def _build_tool_parameters(self, action: Dict) -> Dict:
|
||||
@@ -438,17 +393,13 @@ class ToolExecutor:
|
||||
for k, v in action[param_type]["properties"].items():
|
||||
if v.get("filled_by_llm", True):
|
||||
params["properties"][k] = {
|
||||
key: value
|
||||
for key, value in v.items()
|
||||
if key not in ("filled_by_llm", "value", "required")
|
||||
key: value for key, value in v.items() if key not in ("filled_by_llm", "value", "required")
|
||||
}
|
||||
if v.get("required", False):
|
||||
params["required"].append(k)
|
||||
return params
|
||||
|
||||
def check_pause(
|
||||
self, tools_dict: Dict, call, llm_class_name: str
|
||||
) -> Optional[Dict]:
|
||||
def check_pause(self, tools_dict: Dict, call, llm_class_name: str) -> Optional[Dict]:
|
||||
"""Return a pending-action dict (approval / client / headless_denied) or None.
|
||||
|
||||
In headless mode the dict's pause_type is ``headless_denied`` so the
|
||||
@@ -478,9 +429,7 @@ class ToolExecutor:
|
||||
"llm_name": llm_name,
|
||||
"arguments": arguments,
|
||||
"pause_type": "headless_denied",
|
||||
"deny_reason": (
|
||||
"Client-side tools cannot run in headless / scheduled runs."
|
||||
),
|
||||
"deny_reason": ("Client-side tools cannot run in headless / scheduled runs."),
|
||||
"error_type": "tool_not_allowed",
|
||||
"thought_signature": getattr(call, "thought_signature", None),
|
||||
}
|
||||
@@ -498,9 +447,7 @@ class ToolExecutor:
|
||||
|
||||
# Approval required
|
||||
if tool_data["name"] == "api_tool":
|
||||
action_data = tool_data.get("config", {}).get("actions", {}).get(
|
||||
action_name, {}
|
||||
)
|
||||
action_data = tool_data.get("config", {}).get("actions", {}).get(action_name, {})
|
||||
else:
|
||||
action_data = next(
|
||||
(a for a in tool_data.get("actions", []) if a["name"] == action_name),
|
||||
@@ -517,17 +464,22 @@ class ToolExecutor:
|
||||
# not reflect later approval-mode changes nor command-level
|
||||
# heuristics, so consult the tool directly.
|
||||
if tool_data.get("name") == "remote_device":
|
||||
require_approval, denylist_forced = (
|
||||
self._remote_device_requires_approval(
|
||||
tool_data, action_name, arguments,
|
||||
)
|
||||
require_approval, denylist_forced = self._remote_device_requires_approval(
|
||||
tool_data,
|
||||
action_name,
|
||||
arguments,
|
||||
)
|
||||
elif tool_data.get("name") == "code_executor":
|
||||
# The deployment-level ``config.require_approval`` is authoritative
|
||||
# over the cached action snapshot, so consult the tool directly.
|
||||
require_approval = self._code_executor_requires_approval(
|
||||
tool_data, action_name, arguments,
|
||||
) or require_approval
|
||||
require_approval = (
|
||||
self._code_executor_requires_approval(
|
||||
tool_data,
|
||||
action_name,
|
||||
arguments,
|
||||
)
|
||||
or require_approval
|
||||
)
|
||||
|
||||
if require_approval:
|
||||
if self.headless:
|
||||
@@ -548,10 +500,7 @@ class ToolExecutor:
|
||||
"llm_name": llm_name,
|
||||
"arguments": arguments,
|
||||
"pause_type": "headless_denied",
|
||||
"deny_reason": (
|
||||
"This tool requires approval and is not in the run's "
|
||||
"tool_allowlist."
|
||||
),
|
||||
"deny_reason": ("This tool requires approval and is not in the run's tool_allowlist."),
|
||||
"error_type": "tool_not_allowed",
|
||||
"thought_signature": getattr(call, "thought_signature", None),
|
||||
}
|
||||
@@ -577,7 +526,10 @@ class ToolExecutor:
|
||||
return None
|
||||
|
||||
def _remote_device_requires_approval(
|
||||
self, tool_data: Dict, action_name: str, arguments: Dict,
|
||||
self,
|
||||
tool_data: Dict,
|
||||
action_name: str,
|
||||
arguments: Dict,
|
||||
) -> tuple[bool, bool]:
|
||||
"""Live approval decision for a ``remote_device`` invocation.
|
||||
|
||||
@@ -597,13 +549,15 @@ class ToolExecutor:
|
||||
return tool.preview_decision(action_name, arguments)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"remote_device preview_decision failed; defaulting to a "
|
||||
"forced prompt",
|
||||
"remote_device preview_decision failed; defaulting to a forced prompt",
|
||||
)
|
||||
return True, True
|
||||
|
||||
def _code_executor_requires_approval(
|
||||
self, tool_data: Dict, action_name: str, arguments: Dict,
|
||||
self,
|
||||
tool_data: Dict,
|
||||
action_name: str,
|
||||
arguments: Dict,
|
||||
) -> bool:
|
||||
"""Live approval decision for a ``code_executor`` invocation.
|
||||
|
||||
@@ -662,9 +616,7 @@ class ToolExecutor:
|
||||
user_id=self.user,
|
||||
agent_id=self.agent_id,
|
||||
):
|
||||
_mark_failed(
|
||||
call_id, tool_call_data["result"], user_id=self.user
|
||||
)
|
||||
_mark_failed(call_id, tool_call_data["result"], user_id=self.user)
|
||||
yield {"type": "tool_call", "data": {**tool_call_data, "status": "error"}}
|
||||
self.tool_calls.append(tool_call_data)
|
||||
return "Failed to parse tool call.", call_id
|
||||
@@ -738,10 +690,7 @@ class ToolExecutor:
|
||||
# event and flip the journal row to ``failed`` instead of
|
||||
# killing the stream.
|
||||
if not isinstance(call_args, dict):
|
||||
error_message = (
|
||||
f"Tool call arguments must be a JSON object, got "
|
||||
f"{type(call_args).__name__}."
|
||||
)
|
||||
error_message = f"Tool call arguments must be a JSON object, got {type(call_args).__name__}."
|
||||
tool_call_data["result"] = error_message
|
||||
tool_call_data["arguments"] = {}
|
||||
if proposed_ok:
|
||||
@@ -756,11 +705,7 @@ class ToolExecutor:
|
||||
action_data = (
|
||||
tool_data["config"]["actions"][action_name]
|
||||
if tool_data["name"] == "api_tool"
|
||||
else next(
|
||||
action
|
||||
for action in tool_data["actions"]
|
||||
if action["name"] == action_name
|
||||
)
|
||||
else next(action for action in tool_data["actions"] if action["name"] == action_name)
|
||||
)
|
||||
|
||||
query_params, headers, body, parameters = {}, {}, {}, {}
|
||||
@@ -774,29 +719,25 @@ class ToolExecutor:
|
||||
for param_type, target_dict in param_types.items():
|
||||
if param_type in action_data and action_data[param_type].get("properties"):
|
||||
for param, details in action_data[param_type]["properties"].items():
|
||||
if (
|
||||
param not in call_args
|
||||
and "value" in details
|
||||
and details["value"]
|
||||
):
|
||||
if param not in call_args and "value" in details and details["value"]:
|
||||
target_dict[param] = details["value"]
|
||||
for param, value in call_args.items():
|
||||
for param_type, target_dict in param_types.items():
|
||||
if param_type in action_data and param in action_data[param_type].get(
|
||||
"properties", {}
|
||||
):
|
||||
if param_type in action_data and param in action_data[param_type].get("properties", {}):
|
||||
target_dict[param] = value
|
||||
|
||||
# Load tool (with caching)
|
||||
tool = self._get_or_load_tool(
|
||||
tool_data, tool_id, action_name,
|
||||
headers=headers, query_params=query_params,
|
||||
tool_data,
|
||||
tool_id,
|
||||
action_name,
|
||||
headers=headers,
|
||||
query_params=query_params,
|
||||
)
|
||||
|
||||
if tool is None:
|
||||
error_message = (
|
||||
f"Failed to load tool '{tool_data.get('name')}' (tool_id key={tool_id}): "
|
||||
"missing 'id' on tool row."
|
||||
f"Failed to load tool '{tool_data.get('name')}' (tool_id key={tool_id}): missing 'id' on tool row."
|
||||
)
|
||||
logger.error(
|
||||
"tool_load_failed",
|
||||
@@ -841,11 +782,7 @@ class ToolExecutor:
|
||||
_mark_failed(call_id, str(exc), user_id=self.user)
|
||||
raise
|
||||
|
||||
get_artifact_id = (
|
||||
getattr(tool, "get_artifact_id", None)
|
||||
if tool_data["name"] != "api_tool"
|
||||
else None
|
||||
)
|
||||
get_artifact_id = getattr(tool, "get_artifact_id", None) if tool_data["name"] != "api_tool" else None
|
||||
|
||||
artifact_id = None
|
||||
if callable(get_artifact_id):
|
||||
@@ -864,9 +801,7 @@ class ToolExecutor:
|
||||
result_full = str(result)
|
||||
tool_call_data["resolved_arguments"] = resolved_arguments
|
||||
tool_call_data["result_full"] = result_full
|
||||
tool_call_data["result"] = (
|
||||
f"{result_full[:50]}..." if len(result_full) > 50 else result_full
|
||||
)
|
||||
tool_call_data["result"] = f"{result_full[:50]}..." if len(result_full) > 50 else result_full
|
||||
|
||||
# Tool side effect has run; flip the journal row so the
|
||||
# message-finalize path can later confirm it. If the proposed
|
||||
@@ -887,9 +822,7 @@ class ToolExecutor:
|
||||
)
|
||||
|
||||
stream_tool_call_data = {
|
||||
key: value
|
||||
for key, value in tool_call_data.items()
|
||||
if key not in {"result_full", "resolved_arguments"}
|
||||
key: value for key, value in tool_call_data.items() if key not in {"result_full", "resolved_arguments"}
|
||||
}
|
||||
yield {"type": "tool_call", "data": {**stream_tool_call_data, "status": "completed"}}
|
||||
self.tool_calls.append(tool_call_data)
|
||||
@@ -897,8 +830,12 @@ class ToolExecutor:
|
||||
return result, call_id
|
||||
|
||||
def _get_or_load_tool(
|
||||
self, tool_data: Dict, tool_id: str, action_name: str,
|
||||
headers: Optional[Dict] = None, query_params: Optional[Dict] = None,
|
||||
self,
|
||||
tool_data: Dict,
|
||||
tool_id: str,
|
||||
action_name: str,
|
||||
headers: Optional[Dict] = None,
|
||||
query_params: Optional[Dict] = None,
|
||||
):
|
||||
"""Load a tool, using cache when possible."""
|
||||
cache_key = f"{tool_data['name']}:{tool_id}:{self.user or ''}"
|
||||
@@ -924,12 +861,8 @@ class ToolExecutor:
|
||||
"query_params": query_params or {},
|
||||
}
|
||||
if "body_content_type" in action_config:
|
||||
tool_config["body_content_type"] = action_config.get(
|
||||
"body_content_type", "application/json"
|
||||
)
|
||||
tool_config["body_encoding_rules"] = action_config.get(
|
||||
"body_encoding_rules", {}
|
||||
)
|
||||
tool_config["body_content_type"] = action_config.get("body_content_type", "application/json")
|
||||
tool_config["body_encoding_rules"] = action_config.get("body_encoding_rules", {})
|
||||
else:
|
||||
tool_config = tool_data["config"].copy() if tool_data["config"] else {}
|
||||
# Credentials are PBKDF2-bound to the tool OWNER's sub, not the
|
||||
@@ -955,9 +888,7 @@ class ToolExecutor:
|
||||
"agent_id": self.agent_id,
|
||||
},
|
||||
)
|
||||
decrypted = decrypt_credentials(
|
||||
tool_config["encrypted_credentials"], tool_owner
|
||||
)
|
||||
decrypted = decrypt_credentials(tool_config["encrypted_credentials"], tool_owner)
|
||||
tool_config.update(decrypted)
|
||||
tool_config["auth_credentials"] = decrypted
|
||||
tool_config.pop("encrypted_credentials", None)
|
||||
@@ -975,6 +906,8 @@ class ToolExecutor:
|
||||
tool_config["tool_id"] = str(row_id)
|
||||
if self.conversation_id:
|
||||
tool_config["conversation_id"] = self.conversation_id
|
||||
if self.message_id:
|
||||
tool_config["message_id"] = self.message_id
|
||||
# Carry the request's own attachments so sandbox tools can
|
||||
# lazily bridge a referenced chat attachment (conversation
|
||||
# scope only; workflow nodes bridge attachments up front).
|
||||
@@ -988,9 +921,7 @@ class ToolExecutor:
|
||||
# Agent-bound: stamp schedules.agent_id. Agentless: the tool
|
||||
# falls back to ``origin_conversation_id`` as the schedule's
|
||||
# conversation home.
|
||||
tool_config["agent_id"] = (
|
||||
str(self.agent_id) if self.agent_id else None
|
||||
)
|
||||
tool_config["agent_id"] = str(self.agent_id) if self.agent_id else None
|
||||
if tool_data["name"] == "mcp_tool":
|
||||
tool_config["query_mode"] = True
|
||||
|
||||
@@ -1015,9 +946,7 @@ class ToolExecutor:
|
||||
"arguments": tool_call.get("arguments"),
|
||||
"artifact_id": tool_call.get("artifact_id"),
|
||||
"result": (
|
||||
f"{str(tool_call['result'])[:50]}..."
|
||||
if len(str(tool_call["result"])) > 50
|
||||
else tool_call["result"]
|
||||
f"{str(tool_call['result'])[:50]}..." if len(str(tool_call["result"])) > 50 else tool_call["result"]
|
||||
),
|
||||
"status": "completed",
|
||||
}
|
||||
|
||||
@@ -275,7 +275,7 @@ _RENDERERS: Dict[str, str] = {
|
||||
" if c is None:\n"
|
||||
" return ''\n"
|
||||
" if isinstance(c, str) and c[:1] in ('=', '+', '-', '@', chr(9), chr(13), chr(10)):\n"
|
||||
" return \"'\" + c\n"
|
||||
' return "\'" + c\n'
|
||||
" return c\n"
|
||||
"wb = Workbook()\n"
|
||||
"wb.remove(wb.active)\n"
|
||||
@@ -359,8 +359,8 @@ _RENDERERS: Dict[str, str] = {
|
||||
" 'code{{font-family:ui-monospace,SFMono-Regular,Menlo,monospace}}'\n"
|
||||
")\n"
|
||||
"doc = (\n"
|
||||
" '<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\">'\n"
|
||||
" '<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">'\n"
|
||||
' \'<!doctype html><html lang="en"><head><meta charset="utf-8">\'\n'
|
||||
' \'<meta name="viewport" content="width=device-width,initial-scale=1">\'\n'
|
||||
" '<title>' + esc(title or 'Report') + '</title>'\n"
|
||||
" '<style>' + css + '</style></head><body>'\n"
|
||||
" + ''.join(parts) + '</body></html>'\n"
|
||||
@@ -397,6 +397,7 @@ class ArtifactGeneratorTool(Tool):
|
||||
self.tool_id: Optional[str] = self.config.get("tool_id")
|
||||
self.conversation_id: Optional[str] = self.config.get("conversation_id")
|
||||
self.workflow_run_id: Optional[str] = self.config.get("workflow_run_id")
|
||||
self.message_id: Optional[str] = self.config.get("message_id")
|
||||
self._last_artifact_id: Optional[str] = None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
@@ -530,6 +531,7 @@ class ArtifactGeneratorTool(Tool):
|
||||
title=title,
|
||||
conversation_id=self.conversation_id,
|
||||
workflow_run_id=self.workflow_run_id,
|
||||
message_id=self.message_id,
|
||||
spec=spec,
|
||||
produced_by=self._produced_by("create_artifact", kind),
|
||||
)
|
||||
|
||||
@@ -17,11 +17,15 @@ from application.core.settings import settings
|
||||
from application.sandbox.artifacts_capture import (
|
||||
MAX_CAPTURED_FILES,
|
||||
capture_artifacts,
|
||||
infer_mime as _infer_mime,
|
||||
kind_for_mime as _kind_for_mime,
|
||||
snapshot_signatures,
|
||||
unique_input_path,
|
||||
)
|
||||
from application.sandbox.artifacts_capture import (
|
||||
infer_mime as _infer_mime,
|
||||
)
|
||||
from application.sandbox.artifacts_capture import (
|
||||
kind_for_mime as _kind_for_mime,
|
||||
)
|
||||
from application.sandbox.base import ExecResult
|
||||
from application.sandbox.sandbox_creator import SandboxCreator
|
||||
from application.storage.db.repositories.artifacts import ArtifactsRepository
|
||||
@@ -65,6 +69,7 @@ class CodeExecutorTool(Tool):
|
||||
self.tool_id: Optional[str] = self.config.get("tool_id")
|
||||
self.conversation_id: Optional[str] = self.config.get("conversation_id")
|
||||
self.workflow_run_id: Optional[str] = self.config.get("workflow_run_id")
|
||||
self.message_id: Optional[str] = self.config.get("message_id")
|
||||
# Static, deployment-level approval gate (mirrors the action metadata flag).
|
||||
self._require_approval: bool = bool(self.config.get("require_approval", False))
|
||||
self._last_artifact_id: Optional[str] = None
|
||||
@@ -264,9 +269,7 @@ class CodeExecutorTool(Tool):
|
||||
if bridged_id is None:
|
||||
return {"error": f"input artifact {raw} not found in this conversation/run."}
|
||||
artifact_id = bridged_id
|
||||
artifact = repo.get_artifact_in_parent(
|
||||
artifact_id, conversation_id=self.conversation_id
|
||||
)
|
||||
artifact = repo.get_artifact_in_parent(artifact_id, conversation_id=self.conversation_id)
|
||||
if artifact is None:
|
||||
return {"error": f"input artifact {raw} not found in this conversation/run."}
|
||||
version = repo.get_version(artifact_id, artifact["current_version"])
|
||||
@@ -316,9 +319,7 @@ class CodeExecutorTool(Tool):
|
||||
if attachment is None:
|
||||
return None
|
||||
try:
|
||||
return bridge_attachment(
|
||||
attachment, user_id=self.user_id, conversation_id=self.conversation_id
|
||||
)
|
||||
return bridge_attachment(attachment, user_id=self.user_id, conversation_id=self.conversation_id)
|
||||
except AttachmentBridgeError as exc:
|
||||
return {"error": f"failed to attach {raw}: {exc}"}
|
||||
|
||||
@@ -359,6 +360,7 @@ class CodeExecutorTool(Tool):
|
||||
user_id=self.user_id,
|
||||
conversation_id=self.conversation_id,
|
||||
workflow_run_id=self.workflow_run_id,
|
||||
message_id=self.message_id,
|
||||
produced_by={
|
||||
"tool": "code_executor",
|
||||
"action": "run_code",
|
||||
|
||||
@@ -56,6 +56,7 @@ class ReadDocumentTool(Tool):
|
||||
self.tool_id: Optional[str] = self.config.get("tool_id")
|
||||
self.conversation_id: Optional[str] = self.config.get("conversation_id")
|
||||
self.workflow_run_id: Optional[str] = self.config.get("workflow_run_id")
|
||||
self.message_id: Optional[str] = self.config.get("message_id")
|
||||
self._last_artifact_id: Optional[str] = None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
@@ -222,9 +223,7 @@ class ReadDocumentTool(Tool):
|
||||
timeout = float(getattr(settings, "DOCUMENT_PARSE_TIMEOUT", 120))
|
||||
queue = getattr(settings, "DOCUMENT_PARSE_QUEUE", "parsing")
|
||||
try:
|
||||
async_result = parse_document.apply_async(
|
||||
args=[artifact_id, parent, self.user_id, options], queue=queue
|
||||
)
|
||||
async_result = parse_document.apply_async(args=[artifact_id, parent, self.user_id, options], queue=queue)
|
||||
# The web process (not a worker) awaits here; ``disable_sync_subtasks=False`` keeps
|
||||
# the call correct if invoked from a non-prefork (eventlet/gevent) worker where the
|
||||
# inline branch above still ran but the blanket guard would otherwise raise.
|
||||
@@ -283,16 +282,17 @@ class ReadDocumentTool(Tool):
|
||||
if attachment is None:
|
||||
return None
|
||||
try:
|
||||
return bridge_attachment(
|
||||
attachment, user_id=self.user_id, conversation_id=self.conversation_id
|
||||
)
|
||||
return bridge_attachment(attachment, user_id=self.user_id, conversation_id=self.conversation_id)
|
||||
except AttachmentBridgeError as exc:
|
||||
return {"status": "error", "error": f"failed to attach {raw_id}: {exc}"}
|
||||
|
||||
def _parent(self) -> Dict[str, Any]:
|
||||
"""Build the run-scoped parent dict passed to the worker for its independent re-resolve."""
|
||||
if self.conversation_id is not None:
|
||||
return {"conversation_id": self.conversation_id}
|
||||
parent: Dict[str, Any] = {"conversation_id": self.conversation_id}
|
||||
if self.message_id:
|
||||
parent["message_id"] = self.message_id
|
||||
return parent
|
||||
return {"workflow_run_id": self.workflow_run_id}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@@ -13,7 +13,7 @@ from application.agents.workflows.schemas import (
|
||||
)
|
||||
from application.agents.workflows.workflow_engine import WorkflowEngine
|
||||
from application.core.settings import settings
|
||||
from application.logging import log_activity, LogContext
|
||||
from application.logging import LogContext, log_activity
|
||||
from application.sandbox.artifacts_capture import QuotaExceeded
|
||||
from application.storage.db.base_repository import looks_like_uuid
|
||||
from application.storage.db.repositories.workflow_edges import WorkflowEdgesRepository
|
||||
@@ -51,14 +51,10 @@ class WorkflowAgent(BaseAgent):
|
||||
self._bridge_error: Optional[str] = None
|
||||
|
||||
@log_activity()
|
||||
def gen(
|
||||
self, query: str, log_context: LogContext = None
|
||||
) -> Generator[Dict[str, str], None, None]:
|
||||
def gen(self, query: str, log_context: LogContext = None) -> Generator[Dict[str, str], None, None]:
|
||||
yield from self._gen_inner(query, log_context)
|
||||
|
||||
def _gen_inner(
|
||||
self, query: str, log_context: LogContext
|
||||
) -> Generator[Dict[str, str], None, None]:
|
||||
def _gen_inner(self, query: str, log_context: LogContext) -> Generator[Dict[str, str], None, None]:
|
||||
graph = self._load_workflow_graph()
|
||||
if not graph:
|
||||
yield {"type": "error", "error": "Failed to load workflow configuration."}
|
||||
@@ -73,15 +69,11 @@ class WorkflowAgent(BaseAgent):
|
||||
# the outputs of the run they triggered (authz is run.user_id == caller).
|
||||
workflow_owner_id = self._resolve_owner_id()
|
||||
run_user_id = self._resolve_run_user_id(workflow_owner_id)
|
||||
pg_workflow_id = self._precreate_workflow_run(
|
||||
workflow_owner_id, run_user_id, query
|
||||
)
|
||||
pg_workflow_id = self._precreate_workflow_run(workflow_owner_id, run_user_id, query)
|
||||
self._run_persisted = pg_workflow_id is not None
|
||||
|
||||
try:
|
||||
input_documents, dropped = self._bridge_attachments(
|
||||
run_user_id, persisted=self._run_persisted
|
||||
)
|
||||
input_documents, dropped = self._bridge_attachments(run_user_id, persisted=self._run_persisted)
|
||||
except QuotaExceeded as exc:
|
||||
# The run's input documents exceed the uploader's artifact quota. Surface
|
||||
# a clean error and finalize the pre-created RUNNING row as FAILED rather
|
||||
@@ -94,9 +86,7 @@ class WorkflowAgent(BaseAgent):
|
||||
"Delete some artifacts and try again."
|
||||
),
|
||||
}
|
||||
self._finalize_workflow_run(
|
||||
workflow_owner_id, run_user_id, pg_workflow_id, query
|
||||
)
|
||||
self._finalize_workflow_run(workflow_owner_id, run_user_id, pg_workflow_id, query)
|
||||
return
|
||||
|
||||
# Non-fatal: some attachments were dropped (oversize / unreadable). Tell the
|
||||
@@ -105,10 +95,18 @@ class WorkflowAgent(BaseAgent):
|
||||
yield {"type": "error", "error": " ".join(dropped)}
|
||||
|
||||
self._engine.run_persisted = self._run_persisted
|
||||
yield from self._engine.execute({"input_documents": input_documents}, query)
|
||||
self._finalize_workflow_run(
|
||||
workflow_owner_id, run_user_id, pg_workflow_id, query
|
||||
)
|
||||
interrupted = True
|
||||
try:
|
||||
yield from self._engine.execute({"input_documents": input_documents}, query)
|
||||
interrupted = False
|
||||
finally:
|
||||
self._finalize_workflow_run(
|
||||
workflow_owner_id,
|
||||
run_user_id,
|
||||
pg_workflow_id,
|
||||
query,
|
||||
interrupted=interrupted,
|
||||
)
|
||||
|
||||
def _load_workflow_graph(self) -> Optional[WorkflowGraph]:
|
||||
if self._workflow_data:
|
||||
@@ -167,9 +165,7 @@ class WorkflowAgent(BaseAgent):
|
||||
if not owner_id and isinstance(self.decoded_token, dict):
|
||||
owner_id = self.decoded_token.get("sub")
|
||||
if not owner_id:
|
||||
logger.error(
|
||||
f"Workflow owner not available for workflow load: {self.workflow_id}"
|
||||
)
|
||||
logger.error(f"Workflow owner not available for workflow load: {self.workflow_id}")
|
||||
return None
|
||||
|
||||
with db_readonly() as conn:
|
||||
@@ -179,10 +175,7 @@ class WorkflowAgent(BaseAgent):
|
||||
else:
|
||||
workflow_row = wf_repo.get_by_legacy_id(self.workflow_id, owner_id)
|
||||
if workflow_row is None:
|
||||
logger.error(
|
||||
f"Workflow {self.workflow_id} not found or inaccessible "
|
||||
f"for user {owner_id}"
|
||||
)
|
||||
logger.error(f"Workflow {self.workflow_id} not found or inaccessible for user {owner_id}")
|
||||
return None
|
||||
pg_workflow_id = str(workflow_row["id"])
|
||||
graph_version = workflow_row.get("current_graph_version", 1)
|
||||
@@ -194,10 +187,12 @@ class WorkflowAgent(BaseAgent):
|
||||
graph_version = 1
|
||||
|
||||
node_rows = WorkflowNodesRepository(conn).find_by_version(
|
||||
pg_workflow_id, graph_version,
|
||||
pg_workflow_id,
|
||||
graph_version,
|
||||
)
|
||||
edge_rows = WorkflowEdgesRepository(conn).find_by_version(
|
||||
pg_workflow_id, graph_version,
|
||||
pg_workflow_id,
|
||||
graph_version,
|
||||
)
|
||||
|
||||
workflow = Workflow(
|
||||
@@ -250,9 +245,7 @@ class WorkflowAgent(BaseAgent):
|
||||
"""
|
||||
return getattr(self, "initial_user_id", None) or getattr(self, "user", None) or workflow_owner_id
|
||||
|
||||
def _resolve_owned_workflow_pg_id(
|
||||
self, conn: Any, owner_id: Optional[str]
|
||||
) -> Optional[str]:
|
||||
def _resolve_owned_workflow_pg_id(self, conn: Any, owner_id: Optional[str]) -> Optional[str]:
|
||||
"""Return the owned workflow's PG id, or None for an unowned/draft id."""
|
||||
if not self.workflow_id or not owner_id:
|
||||
return None
|
||||
@@ -278,9 +271,7 @@ class WorkflowAgent(BaseAgent):
|
||||
return None
|
||||
try:
|
||||
with db_session() as conn:
|
||||
pg_workflow_id = self._resolve_owned_workflow_pg_id(
|
||||
conn, workflow_owner_id
|
||||
)
|
||||
pg_workflow_id = self._resolve_owned_workflow_pg_id(conn, workflow_owner_id)
|
||||
if pg_workflow_id is None:
|
||||
return None
|
||||
WorkflowRunsRepository(conn).create(
|
||||
@@ -404,19 +395,24 @@ class WorkflowAgent(BaseAgent):
|
||||
run_user_id: Optional[str],
|
||||
pg_workflow_id: Optional[str],
|
||||
query: str,
|
||||
interrupted: bool = False,
|
||||
) -> None:
|
||||
"""Write the run's terminal status/result; upsert the row if pre-creation was skipped.
|
||||
|
||||
The run is owned by the *runner* (so it stays readable to the caller and
|
||||
matches the pre-created row); the workflow row is resolved by its *owner*.
|
||||
When ``interrupted`` is set (client disconnect / mid-run error), the run is
|
||||
recorded as FAILED regardless of the per-node log, so a partial run is never
|
||||
left looking complete.
|
||||
"""
|
||||
if not self._engine:
|
||||
return
|
||||
try:
|
||||
status = ExecutionStatus.FAILED if interrupted else self._determine_run_status()
|
||||
run = WorkflowRun(
|
||||
workflow_id=self.workflow_id or "unknown",
|
||||
user=run_user_id,
|
||||
status=self._determine_run_status(),
|
||||
status=status,
|
||||
inputs={"query": query},
|
||||
outputs=self._serialize_state(self._engine.state),
|
||||
steps=self._engine.get_execution_summary(),
|
||||
@@ -429,9 +425,7 @@ class WorkflowAgent(BaseAgent):
|
||||
return
|
||||
with db_session() as conn:
|
||||
if pg_workflow_id is None:
|
||||
pg_workflow_id = self._resolve_owned_workflow_pg_id(
|
||||
conn, workflow_owner_id
|
||||
)
|
||||
pg_workflow_id = self._resolve_owned_workflow_pg_id(conn, workflow_owner_id)
|
||||
if pg_workflow_id is None:
|
||||
return
|
||||
runs_repo = WorkflowRunsRepository(conn)
|
||||
@@ -486,10 +480,7 @@ class WorkflowAgent(BaseAgent):
|
||||
|
||||
def _serialize_state_value(self, value: Any) -> Any:
|
||||
if isinstance(value, dict):
|
||||
return {
|
||||
str(dict_key): self._serialize_state_value(dict_value)
|
||||
for dict_key, dict_value in value.items()
|
||||
}
|
||||
return {str(dict_key): self._serialize_state_value(dict_value) for dict_key, dict_value in value.items()}
|
||||
if isinstance(value, list):
|
||||
return [self._serialize_state_value(item) for item in value]
|
||||
if isinstance(value, tuple):
|
||||
|
||||
@@ -38,6 +38,9 @@ artifacts_ns = Namespace("artifacts", description="Artifact operations", path="/
|
||||
_PRESIGNED_URL_TTL = 300
|
||||
|
||||
|
||||
_ARTIFACT_URL_ENVELOPE_MIME = "application/vnd.docsgpt.artifact-url+json"
|
||||
|
||||
|
||||
def _sanitize_header_filename(filename: Optional[str], fallback: str) -> str:
|
||||
"""Strip CRLF / quotes from a display filename for a Content-Disposition header."""
|
||||
if not filename:
|
||||
@@ -50,21 +53,9 @@ def _artifact_summary(artifact: dict) -> dict:
|
||||
"""Project an artifact identity row to its API metadata shape (owner id withheld)."""
|
||||
return {
|
||||
"id": str(artifact.get("id")),
|
||||
"conversation_id": (
|
||||
str(artifact["conversation_id"])
|
||||
if artifact.get("conversation_id") is not None
|
||||
else None
|
||||
),
|
||||
"workflow_run_id": (
|
||||
str(artifact["workflow_run_id"])
|
||||
if artifact.get("workflow_run_id") is not None
|
||||
else None
|
||||
),
|
||||
"message_id": (
|
||||
str(artifact["message_id"])
|
||||
if artifact.get("message_id") is not None
|
||||
else None
|
||||
),
|
||||
"conversation_id": (str(artifact["conversation_id"]) if artifact.get("conversation_id") is not None else None),
|
||||
"workflow_run_id": (str(artifact["workflow_run_id"]) if artifact.get("workflow_run_id") is not None else None),
|
||||
"message_id": (str(artifact["message_id"]) if artifact.get("message_id") is not None else None),
|
||||
"kind": artifact.get("kind"),
|
||||
"title": artifact.get("title"),
|
||||
"metadata": artifact.get("metadata"),
|
||||
@@ -107,20 +98,14 @@ class ListArtifacts(Resource):
|
||||
share_token = request.args.get("share_token")
|
||||
|
||||
if not user_id and not share_token:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Authentication required"}), 401
|
||||
)
|
||||
return make_response(jsonify({"success": False, "message": "Authentication required"}), 401)
|
||||
|
||||
# Gate UUID-shape before any CAST(:id AS uuid) reaches the repo, so a
|
||||
# malformed id is rejected cleanly instead of poisoning the transaction.
|
||||
if conversation_id and not looks_like_uuid(conversation_id):
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Invalid conversation_id"}), 400
|
||||
)
|
||||
return make_response(jsonify({"success": False, "message": "Invalid conversation_id"}), 400)
|
||||
if workflow_run_id and not looks_like_uuid(workflow_run_id):
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Invalid workflow_run_id"}), 400
|
||||
)
|
||||
return make_response(jsonify({"success": False, "message": "Invalid workflow_run_id"}), 400)
|
||||
|
||||
try:
|
||||
with db_readonly() as conn:
|
||||
@@ -133,49 +118,32 @@ class ListArtifacts(Resource):
|
||||
# and scope the query to it. Workflow runs are not agent-scoped,
|
||||
# so reject that filter.
|
||||
if workflow_run_id:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Forbidden"}), 403
|
||||
)
|
||||
return make_response(jsonify({"success": False, "message": "Forbidden"}), 403)
|
||||
if not conversation_id:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Forbidden"}), 403
|
||||
)
|
||||
rows = repo.list_artifacts_for_agent(
|
||||
principal.agent_id, user_id, conversation_id=conversation_id
|
||||
)
|
||||
return make_response(jsonify({"success": False, "message": "Forbidden"}), 403)
|
||||
rows = repo.list_artifacts_for_agent(principal.agent_id, user_id, conversation_id=conversation_id)
|
||||
elif conversation_id:
|
||||
if not user_can_access_conversation(
|
||||
conn, conversation_id, user_id, share_token
|
||||
):
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Forbidden"}), 403
|
||||
)
|
||||
if not user_can_access_conversation(conn, conversation_id, user_id, share_token):
|
||||
return make_response(jsonify({"success": False, "message": "Forbidden"}), 403)
|
||||
rows = repo.list_artifacts(conversation_id=conversation_id)
|
||||
# Owner / shared_with collaborator sees every artifact; a
|
||||
# share-token caller is confined to the shared first_n_queries
|
||||
# snapshot (drop artifacts whose message is outside it or NULL).
|
||||
conv_repo = ConversationsRepository(conn)
|
||||
is_owner = bool(
|
||||
user_id and conv_repo.get(conversation_id, user_id) is not None
|
||||
)
|
||||
is_owner = bool(user_id and conv_repo.get(conversation_id, user_id) is not None)
|
||||
if not is_owner:
|
||||
shared = _shared_row_for(conn, conversation_id, share_token)
|
||||
first_n = int(shared.get("first_n_queries") or 0) if shared else 0
|
||||
snapshot_ids = conv_repo.first_n_message_ids(
|
||||
conversation_id, first_n
|
||||
)
|
||||
snapshot_ids = conv_repo.first_n_message_ids(conversation_id, first_n)
|
||||
rows = [
|
||||
r
|
||||
for r in rows
|
||||
if r.get("message_id") is not None
|
||||
and str(r.get("message_id")) in snapshot_ids
|
||||
if r.get("message_id") is not None and str(r.get("message_id")) in snapshot_ids
|
||||
]
|
||||
elif workflow_run_id:
|
||||
run = WorkflowRunsRepository(conn).get(workflow_run_id)
|
||||
if run is None or run.get("user_id") != user_id:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Forbidden"}), 403
|
||||
)
|
||||
return make_response(jsonify({"success": False, "message": "Forbidden"}), 403)
|
||||
rows = repo.list_artifacts(workflow_run_id=workflow_run_id)
|
||||
else:
|
||||
if not user_id:
|
||||
@@ -186,9 +154,7 @@ class ListArtifacts(Resource):
|
||||
rows = repo.list_artifacts(user_id=user_id)
|
||||
|
||||
return make_response(
|
||||
jsonify(
|
||||
{"success": True, "artifacts": [_artifact_summary(r) for r in rows]}
|
||||
),
|
||||
jsonify({"success": True, "artifacts": [_artifact_summary(r) for r in rows]}),
|
||||
200,
|
||||
)
|
||||
except Exception as err:
|
||||
@@ -201,22 +167,16 @@ class GetArtifact(Resource):
|
||||
@api.doc(description="Get an artifact's metadata, version list, and current spec")
|
||||
def get(self, artifact_id: str):
|
||||
if not looks_like_uuid(artifact_id):
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Artifact not found"}), 404
|
||||
)
|
||||
return make_response(jsonify({"success": False, "message": "Artifact not found"}), 404)
|
||||
principal = resolve_principal()
|
||||
try:
|
||||
with db_readonly() as conn:
|
||||
repo = ArtifactsRepository(conn)
|
||||
artifact = repo.get_artifact(artifact_id)
|
||||
if artifact is None:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Artifact not found"}), 404
|
||||
)
|
||||
return make_response(jsonify({"success": False, "message": "Artifact not found"}), 404)
|
||||
if not authorize_artifact(conn, artifact, principal):
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Forbidden"}), 403
|
||||
)
|
||||
return make_response(jsonify({"success": False, "message": "Forbidden"}), 403)
|
||||
versions = repo.list_versions(artifact_id)
|
||||
current = repo.get_version(artifact_id, artifact.get("current_version"))
|
||||
|
||||
@@ -231,24 +191,18 @@ class GetArtifact(Resource):
|
||||
@api.doc(description="Delete an artifact and all its versions (owner only)")
|
||||
def delete(self, artifact_id: str):
|
||||
if not looks_like_uuid(artifact_id):
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Artifact not found"}), 404
|
||||
)
|
||||
return make_response(jsonify({"success": False, "message": "Artifact not found"}), 404)
|
||||
principal = resolve_principal()
|
||||
try:
|
||||
with db_session() as conn:
|
||||
repo = ArtifactsRepository(conn)
|
||||
artifact = repo.get_artifact(artifact_id)
|
||||
if artifact is None:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Artifact not found"}), 404
|
||||
)
|
||||
return make_response(jsonify({"success": False, "message": "Artifact not found"}), 404)
|
||||
# Delete is a WRITE: only the parent owner may delete; share
|
||||
# links / read-only collaborators are denied (read access only).
|
||||
if not authorize_artifact_write(conn, artifact, principal):
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Forbidden"}), 403
|
||||
)
|
||||
return make_response(jsonify({"success": False, "message": "Forbidden"}), 403)
|
||||
storage_paths = repo.delete_artifact(artifact_id)
|
||||
# Reap the bytes best-effort AFTER the row delete commits.
|
||||
_reap_storage(storage_paths)
|
||||
@@ -271,9 +225,7 @@ def _reap_storage(paths: list) -> None:
|
||||
try:
|
||||
storage.delete_file(path)
|
||||
except Exception:
|
||||
current_app.logger.warning(
|
||||
"artifact delete: failed to delete bytes %s", path, exc_info=True
|
||||
)
|
||||
current_app.logger.warning("artifact delete: failed to delete bytes %s", path, exc_info=True)
|
||||
|
||||
|
||||
@artifacts_ns.route("/artifacts/<artifact_id>/versions/<int:version>")
|
||||
@@ -281,37 +233,25 @@ class GetArtifactVersion(Resource):
|
||||
@api.doc(description="Get a single artifact version's metadata and spec")
|
||||
def get(self, artifact_id: str, version: int):
|
||||
if not looks_like_uuid(artifact_id):
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Artifact not found"}), 404
|
||||
)
|
||||
return make_response(jsonify({"success": False, "message": "Artifact not found"}), 404)
|
||||
principal = resolve_principal()
|
||||
try:
|
||||
with db_readonly() as conn:
|
||||
repo = ArtifactsRepository(conn)
|
||||
artifact = repo.get_artifact(artifact_id)
|
||||
if artifact is None:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Artifact not found"}), 404
|
||||
)
|
||||
return make_response(jsonify({"success": False, "message": "Artifact not found"}), 404)
|
||||
if not authorize_artifact(conn, artifact, principal):
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Forbidden"}), 403
|
||||
)
|
||||
return make_response(jsonify({"success": False, "message": "Forbidden"}), 403)
|
||||
version_row = repo.get_version(artifact_id, version)
|
||||
if version_row is None:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Version not found"}), 404
|
||||
)
|
||||
return make_response(jsonify({"success": False, "message": "Version not found"}), 404)
|
||||
return make_response(
|
||||
jsonify(
|
||||
{"success": True, "version": _version_summary(version_row, include_spec=True)}
|
||||
),
|
||||
jsonify({"success": True, "version": _version_summary(version_row, include_spec=True)}),
|
||||
200,
|
||||
)
|
||||
except Exception as err:
|
||||
current_app.logger.error(
|
||||
f"Error retrieving artifact version: {err}", exc_info=True
|
||||
)
|
||||
current_app.logger.error(f"Error retrieving artifact version: {err}", exc_info=True)
|
||||
return make_response(jsonify({"success": False}), 400)
|
||||
|
||||
|
||||
@@ -320,9 +260,7 @@ class DownloadArtifact(Resource):
|
||||
@api.doc(description="Download an artifact's bytes (302 to a presigned URL on S3)")
|
||||
def get(self, artifact_id: str):
|
||||
if not looks_like_uuid(artifact_id):
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Artifact not found"}), 404
|
||||
)
|
||||
return make_response(jsonify({"success": False, "message": "Artifact not found"}), 404)
|
||||
principal = resolve_principal()
|
||||
version_arg = request.args.get("version")
|
||||
try:
|
||||
@@ -330,37 +268,25 @@ class DownloadArtifact(Resource):
|
||||
repo = ArtifactsRepository(conn)
|
||||
artifact = repo.get_artifact(artifact_id)
|
||||
if artifact is None:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Artifact not found"}), 404
|
||||
)
|
||||
return make_response(jsonify({"success": False, "message": "Artifact not found"}), 404)
|
||||
if not authorize_artifact(conn, artifact, principal):
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Forbidden"}), 403
|
||||
)
|
||||
return make_response(jsonify({"success": False, "message": "Forbidden"}), 403)
|
||||
version = artifact.get("current_version")
|
||||
if version_arg is not None:
|
||||
try:
|
||||
version = int(version_arg)
|
||||
except ValueError:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Invalid version"}), 400
|
||||
)
|
||||
return make_response(jsonify({"success": False, "message": "Invalid version"}), 400)
|
||||
version_row = repo.get_version(artifact_id, version)
|
||||
|
||||
if version_row is None:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Version not found"}), 404
|
||||
)
|
||||
return make_response(jsonify({"success": False, "message": "Version not found"}), 404)
|
||||
# The object key is derived only from the stored path, never client input.
|
||||
storage_path = version_row.get("storage_path")
|
||||
if not storage_path:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "No file for this version"}), 404
|
||||
)
|
||||
return make_response(jsonify({"success": False, "message": "No file for this version"}), 404)
|
||||
|
||||
filename = _sanitize_header_filename(
|
||||
version_row.get("filename"), f"artifact-{artifact_id}"
|
||||
)
|
||||
filename = _sanitize_header_filename(version_row.get("filename"), f"artifact-{artifact_id}")
|
||||
mime_type = version_row.get("mime_type") or "application/octet-stream"
|
||||
storage = StorageCreator.get_storage()
|
||||
|
||||
@@ -370,18 +296,14 @@ class DownloadArtifact(Resource):
|
||||
# the operator expected to be off the hot path.
|
||||
if getattr(settings, "URL_STRATEGY", "backend") == "s3":
|
||||
try:
|
||||
url = storage.generate_presigned_url(
|
||||
storage_path, expires_in=_PRESIGNED_URL_TTL
|
||||
)
|
||||
url = storage.generate_presigned_url(storage_path, expires_in=_PRESIGNED_URL_TTL)
|
||||
except NotImplementedError:
|
||||
current_app.logger.error(
|
||||
"URL_STRATEGY=s3 but %s cannot mint presigned URLs",
|
||||
type(storage).__name__,
|
||||
)
|
||||
return make_response(
|
||||
jsonify(
|
||||
{"success": False, "message": "Storage misconfigured"}
|
||||
),
|
||||
jsonify({"success": False, "message": "Storage misconfigured"}),
|
||||
500,
|
||||
)
|
||||
# A 302 to a cross-origin S3 URL can't be read by the app's authed
|
||||
@@ -392,7 +314,17 @@ class DownloadArtifact(Resource):
|
||||
if request.args.get("disposition") == "url" or (
|
||||
"application/json" in request.headers.get("Accept", "")
|
||||
):
|
||||
return make_response(jsonify({"success": True, "url": url}), 200)
|
||||
resp = make_response(jsonify({"success": True, "url": url}), 200)
|
||||
# Tag the envelope with a distinctive media type so the client
|
||||
# keys off a server-set signal, not the JSON body shape. A
|
||||
# ``data`` artifact whose bytes happen to be
|
||||
# ``{"success":true,"url":"..."}`` is streamed under the backend
|
||||
# strategy with its own (``application/json``) content-type and
|
||||
# can never carry this vendor type, so it can't be mistaken for a
|
||||
# redirect (open-redirect gadget). Content-Type is CORS-safelisted,
|
||||
# so this stays readable cross-origin without expose-headers.
|
||||
resp.headers["Content-Type"] = _ARTIFACT_URL_ENVELOPE_MIME
|
||||
return resp
|
||||
return redirect(url, code=302)
|
||||
|
||||
# Stream the bytes in chunks instead of buffering the whole object in
|
||||
@@ -411,18 +343,12 @@ class DownloadArtifact(Resource):
|
||||
return Response(
|
||||
stream_with_context(_stream()),
|
||||
mimetype=mime_type,
|
||||
headers={
|
||||
"Content-Disposition": f'attachment; filename="{filename}"'
|
||||
},
|
||||
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||
)
|
||||
except FileNotFoundError:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "File not found"}), 404
|
||||
)
|
||||
return make_response(jsonify({"success": False, "message": "File not found"}), 404)
|
||||
except Exception as err:
|
||||
current_app.logger.error(
|
||||
f"Error downloading artifact: {err}", exc_info=True
|
||||
)
|
||||
current_app.logger.error(f"Error downloading artifact: {err}", exc_info=True)
|
||||
return make_response(jsonify({"success": False}), 400)
|
||||
|
||||
|
||||
@@ -431,43 +357,31 @@ class RestoreArtifact(Resource):
|
||||
@api.doc(description="Restore a prior version by appending it as the new current version")
|
||||
def post(self, artifact_id: str):
|
||||
if not looks_like_uuid(artifact_id):
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Artifact not found"}), 404
|
||||
)
|
||||
return make_response(jsonify({"success": False, "message": "Artifact not found"}), 404)
|
||||
principal = resolve_principal()
|
||||
data = request.get_json(silent=True) or {}
|
||||
target_version = data.get("version")
|
||||
if target_version is None:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Missing version"}), 400
|
||||
)
|
||||
return make_response(jsonify({"success": False, "message": "Missing version"}), 400)
|
||||
try:
|
||||
target_version = int(target_version)
|
||||
except (ValueError, TypeError):
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Invalid version"}), 400
|
||||
)
|
||||
return make_response(jsonify({"success": False, "message": "Invalid version"}), 400)
|
||||
|
||||
try:
|
||||
with db_session() as conn:
|
||||
repo = ArtifactsRepository(conn)
|
||||
artifact = repo.get_artifact(artifact_id)
|
||||
if artifact is None:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Artifact not found"}), 404
|
||||
)
|
||||
return make_response(jsonify({"success": False, "message": "Artifact not found"}), 404)
|
||||
# Restore is a WRITE (it appends a new current version); share
|
||||
# links / shared_with collaborators inherit read access only, so
|
||||
# gate on the stricter owner-required write check.
|
||||
if not authorize_artifact_write(conn, artifact, principal):
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Forbidden"}), 403
|
||||
)
|
||||
return make_response(jsonify({"success": False, "message": "Forbidden"}), 403)
|
||||
source = repo.get_version(artifact_id, target_version)
|
||||
if source is None:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Version not found"}), 404
|
||||
)
|
||||
return make_response(jsonify({"success": False, "message": "Version not found"}), 404)
|
||||
new_version = repo.append_version(
|
||||
artifact_id,
|
||||
mime_type=source.get("mime_type"),
|
||||
@@ -480,9 +394,7 @@ class RestoreArtifact(Resource):
|
||||
produced_by=source.get("produced_by"),
|
||||
)
|
||||
return make_response(
|
||||
jsonify(
|
||||
{"success": True, "version": _version_summary(new_version, include_spec=True)}
|
||||
),
|
||||
jsonify({"success": True, "version": _version_summary(new_version, include_spec=True)}),
|
||||
200,
|
||||
)
|
||||
except Exception as err:
|
||||
|
||||
@@ -395,6 +395,33 @@ def reap_sandbox_sessions(self):
|
||||
return {"reaped": len(reaped)}
|
||||
|
||||
|
||||
@celery.task(bind=True, acks_late=False)
|
||||
def reap_stale_workflow_runs(self):
|
||||
"""Fail workflow runs stranded in ``running`` past the stale deadline.
|
||||
|
||||
A run row is pre-created as ``running`` and finalized when its generator
|
||||
finishes; a client disconnect or worker crash can leave it ``running``
|
||||
forever. This closes those rows out so the UI/API stop showing a run that
|
||||
will never complete.
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from application.core.settings import settings
|
||||
from application.storage.db.engine import get_engine
|
||||
from application.storage.db.repositories.workflow_runs import WorkflowRunsRepository
|
||||
|
||||
try:
|
||||
stale_seconds = max(60, int(settings.WORKFLOW_RUN_STALE_SECONDS))
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(seconds=stale_seconds)
|
||||
engine = get_engine()
|
||||
with engine.begin() as conn:
|
||||
reaped = WorkflowRunsRepository(conn).mark_stale_running_failed(cutoff)
|
||||
except Exception: # noqa: BLE001 - housekeeping must never crash the beat loop
|
||||
logging.getLogger(__name__).exception("reap_stale_workflow_runs failed")
|
||||
return {"reaped": 0, "error": True}
|
||||
return {"reaped": reaped}
|
||||
|
||||
|
||||
@celery.on_after_configure.connect
|
||||
def setup_periodic_tasks(sender, **kwargs):
|
||||
from application.core.settings import settings
|
||||
@@ -468,6 +495,13 @@ def setup_periodic_tasks(sender, **kwargs):
|
||||
reap_sandbox_sessions.s(),
|
||||
name="reap-sandbox-sessions",
|
||||
)
|
||||
# Fail workflow runs stranded in ``running`` (client disconnect / crash) so
|
||||
# they don't linger forever. Every few minutes is plenty; the cutoff is hours.
|
||||
sender.add_periodic_task(
|
||||
timedelta(seconds=300),
|
||||
reap_stale_workflow_runs.s(),
|
||||
name="reap-stale-workflow-runs",
|
||||
)
|
||||
|
||||
|
||||
# Bound time limits so a hung OAuth discovery (user never finishes the
|
||||
|
||||
@@ -251,18 +251,10 @@ class Settings(BaseSettings):
|
||||
|
||||
# Config-free tools on by default in agentless chats. ``scheduler`` is
|
||||
# dual-registered (also in ``BUILTIN_AGENT_TOOLS``) so the same synthetic id
|
||||
# resolves whether reached via defaults or the agent picker. ``artifact_generator``
|
||||
# persists artifacts (not a ``user_tools``-FK table); its synthetic-id load is
|
||||
# user- and conversation-scoped like ``scheduler`` and it renders html/markdown/code
|
||||
# without a sandbox runner, so it is safe to default on. ``code_executor`` is NOT
|
||||
# default-on: it needs a running sandbox runner and executes LLM-authored code, so a
|
||||
# fresh deploy without a runner would otherwise surface a tool that hard-fails. Enable
|
||||
# it per-agent instead (opt-in via the agent tool picker).
|
||||
DEFAULT_CHAT_TOOLS: list = [
|
||||
"memory",
|
||||
"read_webpage",
|
||||
"scheduler",
|
||||
"artifact_generator",
|
||||
]
|
||||
|
||||
# Conversation Compression Settings
|
||||
@@ -358,6 +350,8 @@ class Settings(BaseSettings):
|
||||
DOCUMENT_PARSE_QUEUE: str = "parsing" # queue the parse_document task is routed to
|
||||
DOCUMENT_PARSE_TIMEOUT: int = 120 # seconds the tool awaits the enqueued parse before degrading
|
||||
DOCUMENT_PARSE_MAX_BYTES: int = 0 # cap on a parsed document's bytes (0 = reuse SANDBOX_MAX_INPUT_BYTES)
|
||||
DOCUMENT_MAX_DECOMPRESSED_BYTES: int = 300 * 1024 * 1024
|
||||
DOCUMENT_MAX_ARCHIVE_ENTRIES: int = 10000
|
||||
# Per-agent-node cap on files passed natively to the node's LLM (vision/doc
|
||||
# inputs). Files past the cap are extracted to text or dropped, not attached
|
||||
# natively, to bound context/cost. Re-uses SANDBOX_MAX_INPUT_BYTES per file.
|
||||
@@ -368,6 +362,11 @@ class Settings(BaseSettings):
|
||||
# avoid serializing dozens of parses; documents past the cap are skipped with
|
||||
# a truncation note instead of extracted.
|
||||
WORKFLOW_NODE_EXTRACT_MAX_FILES: int = 5
|
||||
# A workflow run row is pre-created as ``running`` and finalized when its
|
||||
# generator completes; a client disconnect or worker crash can strand it in
|
||||
# ``running`` forever. The beat reaper fails runs still ``running`` past this
|
||||
# many seconds. Generous so a legitimately long run is never cut off.
|
||||
WORKFLOW_RUN_STALE_SECONDS: int = 3600
|
||||
# Runner container resource caps — consumed by the docsgpt-sandbox compose
|
||||
# service (deployment/sandbox), not by the app client. cgroup CPU/mem caps
|
||||
# are part of the untrusted-code security boundary.
|
||||
|
||||
@@ -9,9 +9,11 @@ temp cleanup — so a hostile filename or document is treated as inert data.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
@@ -98,6 +100,31 @@ def _max_input_bytes() -> int:
|
||||
return int(getattr(settings, "SANDBOX_MAX_INPUT_BYTES", 25 * 1024 * 1024))
|
||||
|
||||
|
||||
_ZIP_CONTAINER_EXTENSIONS = frozenset({".docx", ".xlsx", ".pptx", ".epub"})
|
||||
|
||||
|
||||
def _reject_zip_bomb(data: bytes, suffix: str) -> Optional[str]:
|
||||
"""Return an error string if a zip-based document declares an implausible expansion, else None."""
|
||||
if suffix not in _ZIP_CONTAINER_EXTENSIONS:
|
||||
return None
|
||||
max_entries = int(getattr(settings, "DOCUMENT_MAX_ARCHIVE_ENTRIES", 10000))
|
||||
cap = int(getattr(settings, "DOCUMENT_MAX_DECOMPRESSED_BYTES", 300 * 1024 * 1024))
|
||||
try:
|
||||
with zipfile.ZipFile(io.BytesIO(data)) as zf:
|
||||
infos = zf.infolist()
|
||||
if len(infos) > max_entries:
|
||||
return f"document archive has too many entries ({len(infos)} > {max_entries})."
|
||||
total = 0
|
||||
for info in infos:
|
||||
total += info.file_size
|
||||
if total > cap:
|
||||
return f"document decompresses to too much data: exceeds the {cap}-byte cap."
|
||||
except zipfile.BadZipFile:
|
||||
# Not a readable zip; the format-specific parser will surface a clean error.
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_ocr_enabled(ocr: str) -> bool:
|
||||
"""Resolve the OCR flag from the ``ocr`` arg and the deployment setting."""
|
||||
if ocr == "on":
|
||||
@@ -178,9 +205,7 @@ def _compact_table(table: Dict[str, Any]) -> Dict[str, Any]:
|
||||
return compact
|
||||
|
||||
|
||||
def _docling_structured(
|
||||
path: Path, *, ocr_enabled: bool, include_tables: bool, parser: Any = None
|
||||
) -> Dict[str, Any]:
|
||||
def _docling_structured(path: Path, *, ocr_enabled: bool, include_tables: bool, parser: Any = None) -> Dict[str, Any]:
|
||||
"""Convert a document with Docling and return markdown + structured dict + bounded tables.
|
||||
|
||||
When ``parser`` is the configured ``DoclingParser`` (the collapse-the-double-convert
|
||||
@@ -307,6 +332,10 @@ def parse_document_bytes(
|
||||
if len(data) > cap:
|
||||
return {"error": f"input document is too large: {len(data)} bytes exceeds the {cap}-byte cap."}
|
||||
|
||||
bomb = _reject_zip_bomb(data, suffix)
|
||||
if bomb is not None:
|
||||
return {"error": bomb}
|
||||
|
||||
ocr_enabled = _resolve_ocr_enabled(ocr)
|
||||
tmp_dir = tempfile.mkdtemp(prefix="docparse-")
|
||||
tmp_path = Path(tmp_dir) / safe_name
|
||||
@@ -364,9 +393,7 @@ def _shape(
|
||||
# (Docling/torch conversion dominates the cost, so a re-convert ~doubles latency).
|
||||
if wants_tables and _is_docling_parser(parser):
|
||||
try:
|
||||
extracted = _docling_structured(
|
||||
path, ocr_enabled=ocr_enabled, include_tables=True, parser=parser
|
||||
)
|
||||
extracted = _docling_structured(path, ocr_enabled=ocr_enabled, include_tables=True, parser=parser)
|
||||
text = extracted["markdown"]
|
||||
tables: List[Dict[str, Any]] = [_compact_table(t) for t in extracted["tables"]]
|
||||
except Exception:
|
||||
@@ -392,8 +419,10 @@ def _shape(
|
||||
tables: List[Dict[str, Any]] = []
|
||||
if wants_tables:
|
||||
try:
|
||||
tables = [_compact_table(t) for t in _docling_structured(
|
||||
path, ocr_enabled=ocr_enabled, include_tables=True)["tables"]]
|
||||
tables = [
|
||||
_compact_table(t)
|
||||
for t in _docling_structured(path, ocr_enabled=ocr_enabled, include_tables=True)["tables"]
|
||||
]
|
||||
except Exception:
|
||||
tables = []
|
||||
bounded, truncated = _bounded(text)
|
||||
|
||||
@@ -38,5 +38,7 @@ The user attached these files to this message:
|
||||
{% for f in attachments.files %}
|
||||
- {{ f.filename }} ({{ f.mime_type }}{% if f.size %}, {{ f.size }} bytes{% endif %})
|
||||
{% endfor %}
|
||||
You can pass any attached file to the code tool by its name. If a file is not one you can read directly, use the code tool to read or process it.
|
||||
{% if tools.enabled is not defined or 'code_executor' in tools.enabled or 'read_document' in tools.enabled %}
|
||||
You can pass any attached file to a code or document tool by its name to read or process it.
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
@@ -37,5 +37,7 @@ The user attached these files to this message:
|
||||
{% for f in attachments.files %}
|
||||
- {{ f.filename }} ({{ f.mime_type }}{% if f.size %}, {{ f.size }} bytes{% endif %})
|
||||
{% endfor %}
|
||||
You can pass any attached file to the code tool by its name. If a file is not one you can read directly, use the code tool to read or process it.
|
||||
{% if tools.enabled is not defined or 'code_executor' in tools.enabled or 'read_document' in tools.enabled %}
|
||||
You can pass any attached file to a code or document tool by its name to read or process it.
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
@@ -37,5 +37,7 @@ The user attached these files to this message:
|
||||
{% for f in attachments.files %}
|
||||
- {{ f.filename }} ({{ f.mime_type }}{% if f.size %}, {{ f.size }} bytes{% endif %})
|
||||
{% endfor %}
|
||||
You can pass any attached file to the code tool by its name. If a file is not one you can read directly, use the code tool to read or process it.
|
||||
{% if tools.enabled is not defined or 'code_executor' in tools.enabled or 'read_document' in tools.enabled %}
|
||||
You can pass any attached file to a code or document tool by its name to read or process it.
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
@@ -48,5 +48,7 @@ The user attached these files to this message:
|
||||
{% for f in attachments.files %}
|
||||
- {{ f.filename }} ({{ f.mime_type }}{% if f.size %}, {{ f.size }} bytes{% endif %})
|
||||
{% endfor %}
|
||||
You can pass any attached file to the code tool by its name. If a file is not one you can read directly, use the code tool to read or process it.
|
||||
{% if tools.enabled is not defined or 'code_executor' in tools.enabled or 'read_document' in tools.enabled %}
|
||||
You can pass any attached file to a code or document tool by its name to read or process it.
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
@@ -47,5 +47,7 @@ The user attached these files to this message:
|
||||
{% for f in attachments.files %}
|
||||
- {{ f.filename }} ({{ f.mime_type }}{% if f.size %}, {{ f.size }} bytes{% endif %})
|
||||
{% endfor %}
|
||||
You can pass any attached file to the code tool by its name. If a file is not one you can read directly, use the code tool to read or process it.
|
||||
{% if tools.enabled is not defined or 'code_executor' in tools.enabled or 'read_document' in tools.enabled %}
|
||||
You can pass any attached file to a code or document tool by its name to read or process it.
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
@@ -47,5 +47,7 @@ The user attached these files to this message:
|
||||
{% for f in attachments.files %}
|
||||
- {{ f.filename }} ({{ f.mime_type }}{% if f.size %}, {{ f.size }} bytes{% endif %})
|
||||
{% endfor %}
|
||||
You can pass any attached file to the code tool by its name. If a file is not one you can read directly, use the code tool to read or process it.
|
||||
{% if tools.enabled is not defined or 'code_executor' in tools.enabled or 'read_document' in tools.enabled %}
|
||||
You can pass any attached file to a code or document tool by its name to read or process it.
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
@@ -149,6 +149,7 @@ def capture_artifacts(
|
||||
user_id: str,
|
||||
conversation_id: Optional[str] = None,
|
||||
workflow_run_id: Optional[str] = None,
|
||||
message_id: Optional[str] = None,
|
||||
produced_by: Optional[Dict[str, Any]] = None,
|
||||
outputs: Optional[List[str]] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
@@ -198,6 +199,7 @@ def capture_artifacts(
|
||||
user_id=user_id,
|
||||
conversation_id=conversation_id,
|
||||
workflow_run_id=workflow_run_id,
|
||||
message_id=message_id,
|
||||
produced_by=produced_by,
|
||||
)
|
||||
except QuotaExceeded:
|
||||
@@ -217,6 +219,7 @@ def persist_artifact(
|
||||
user_id: str,
|
||||
conversation_id: Optional[str] = None,
|
||||
workflow_run_id: Optional[str] = None,
|
||||
message_id: Optional[str] = None,
|
||||
produced_by: Optional[Dict[str, Any]] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Store a captured workspace file as a new artifact (kind/mime inferred from its name)."""
|
||||
@@ -234,6 +237,7 @@ def persist_artifact(
|
||||
title=display_name,
|
||||
conversation_id=conversation_id,
|
||||
workflow_run_id=workflow_run_id,
|
||||
message_id=message_id,
|
||||
produced_by=produced_by,
|
||||
)
|
||||
|
||||
@@ -341,6 +345,7 @@ def persist_new_artifact(
|
||||
title: Optional[str] = None,
|
||||
conversation_id: Optional[str] = None,
|
||||
workflow_run_id: Optional[str] = None,
|
||||
message_id: Optional[str] = None,
|
||||
spec: Any = None,
|
||||
preview_text: Optional[str] = None,
|
||||
produced_by: Any = None,
|
||||
@@ -366,6 +371,7 @@ def persist_new_artifact(
|
||||
kind,
|
||||
conversation_id=conversation_id,
|
||||
workflow_run_id=workflow_run_id,
|
||||
message_id=message_id,
|
||||
title=title or safe_name,
|
||||
mime_type=mime_type,
|
||||
filename=safe_name,
|
||||
|
||||
@@ -392,13 +392,29 @@ class ArtifactsRepository:
|
||||
return int(row[0]) if row is not None else 0
|
||||
|
||||
def total_bytes_for_user(self, user_id: str) -> int:
|
||||
"""Return the summed byte size of every version a user owns (quota accounting)."""
|
||||
"""Return the stored byte size a user owns (quota accounting).
|
||||
|
||||
Deduplicated by ``storage_path``: ``restore`` appends a version that
|
||||
re-points at an existing version's stored object (same key + size), so
|
||||
summing every version row would charge the same bytes N times and inflate
|
||||
usage without any new bytes being written. Each distinct stored object is
|
||||
counted once; versions with no object yet (``storage_path IS NULL`` --
|
||||
spec-only, whose size is normally NULL too) are counted individually since
|
||||
they share no key to dedupe on.
|
||||
"""
|
||||
row = self._conn.execute(
|
||||
text(
|
||||
"SELECT COALESCE(SUM(v.size), 0) "
|
||||
"FROM artifact_versions v "
|
||||
"JOIN artifacts a ON a.id = v.artifact_id "
|
||||
"WHERE a.user_id = :user_id"
|
||||
"SELECT COALESCE(SUM(t.size), 0) FROM ("
|
||||
" SELECT DISTINCT v.storage_path, v.size "
|
||||
" FROM artifact_versions v "
|
||||
" JOIN artifacts a ON a.id = v.artifact_id "
|
||||
" WHERE a.user_id = :user_id AND v.storage_path IS NOT NULL "
|
||||
" UNION ALL "
|
||||
" SELECT v.storage_path, v.size "
|
||||
" FROM artifact_versions v "
|
||||
" JOIN artifacts a ON a.id = v.artifact_id "
|
||||
" WHERE a.user_id = :user_id AND v.storage_path IS NULL"
|
||||
") t"
|
||||
),
|
||||
{"user_id": user_id},
|
||||
).fetchone()
|
||||
|
||||
@@ -9,7 +9,7 @@ from __future__ import annotations
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import Connection, text
|
||||
from sqlalchemy import Connection, func, text
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
|
||||
from application.storage.db.base_repository import row_to_dict
|
||||
@@ -87,6 +87,28 @@ class WorkflowRunsRepository:
|
||||
res = self._conn.execute(stmt)
|
||||
return res.rowcount > 0
|
||||
|
||||
def mark_stale_running_failed(self, older_than: datetime) -> int:
|
||||
"""Fail runs left in ``running`` (no ``ended_at``) since before ``older_than``.
|
||||
|
||||
The run row is pre-created as ``running`` and finalized when its generator
|
||||
finishes; a client disconnect or a worker crash can strand it in ``running``
|
||||
forever, since nothing else finalizes it. This closes those rows out so they
|
||||
don't linger. Returns the number of rows updated.
|
||||
"""
|
||||
stmt = (
|
||||
workflow_runs_table.update()
|
||||
.where(workflow_runs_table.c.status == "running")
|
||||
.where(workflow_runs_table.c.ended_at.is_(None))
|
||||
.where(workflow_runs_table.c.started_at < older_than)
|
||||
.values(
|
||||
status="failed",
|
||||
ended_at=func.now(),
|
||||
result={"error": "Run did not complete (timed out or the client disconnected)."},
|
||||
)
|
||||
)
|
||||
res = self._conn.execute(stmt)
|
||||
return res.rowcount
|
||||
|
||||
def get(self, run_id: str) -> Optional[dict]:
|
||||
res = self._conn.execute(
|
||||
text("SELECT * FROM workflow_runs WHERE id = CAST(:id AS uuid)"),
|
||||
|
||||
@@ -1718,6 +1718,7 @@ def _persist_parse_result(result, title, user_id, parent, options):
|
||||
title=f"{title} (parsed)",
|
||||
conversation_id=parent.get("conversation_id"),
|
||||
workflow_run_id=parent.get("workflow_run_id"),
|
||||
message_id=parent.get("message_id"),
|
||||
produced_by={"tool": "read_document", "action": "read_document", "tool_id": options.get("tool_id")},
|
||||
)
|
||||
except QuotaExceeded as exc:
|
||||
|
||||
@@ -18,21 +18,8 @@ services:
|
||||
- CELERY_BROKER_URL=redis://redis:6379/0
|
||||
- CELERY_RESULT_BACKEND=redis://redis:6379/1
|
||||
- POSTGRES_URI=postgresql://docsgpt:docsgpt@postgres:5432/docsgpt
|
||||
# Code-execution runner reached over HTTP + WebSocket (no docker socket).
|
||||
- SANDBOX_GATEWAY_URL=http://docsgpt-sandbox:8888
|
||||
# Env-scrubbing kernelspec the docsgpt-sandbox image installs.
|
||||
- SANDBOX_KERNEL_NAME=docsgpt-python
|
||||
ports:
|
||||
- "7091:7091"
|
||||
networks:
|
||||
# `default` reaches the internet (LLM APIs) + frontend/host; `data-net`
|
||||
# reaches redis/postgres; `sandbox-net` reaches the code-exec runner over an
|
||||
# internal-only net (so the runner needs no shared internet bridge to be
|
||||
# reachable, and the egress overlay can cut its internet without severing
|
||||
# this control path).
|
||||
- default
|
||||
- data-net
|
||||
- sandbox-net
|
||||
volumes:
|
||||
- ../application/indexes:/app/application/indexes
|
||||
- ../application/inputs:/app/application/inputs
|
||||
@@ -55,75 +42,32 @@ services:
|
||||
- CELERY_RESULT_BACKEND=redis://redis:6379/1
|
||||
- API_URL=http://backend:7091
|
||||
- POSTGRES_URI=postgresql://docsgpt:docsgpt@postgres:5432/docsgpt
|
||||
- SANDBOX_GATEWAY_URL=http://docsgpt-sandbox:8888
|
||||
- SANDBOX_KERNEL_NAME=docsgpt-python
|
||||
networks:
|
||||
# See backend: control-plane access to the code-exec runner over sandbox-net
|
||||
# (workflow code nodes run in the worker).
|
||||
- default
|
||||
- data-net
|
||||
- sandbox-net
|
||||
depends_on:
|
||||
redis:
|
||||
condition: service_started
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
|
||||
# Opt-in code-execution runner (Jupyter Kernel Gateway). Started ONLY with
|
||||
# `docker compose --profile sandbox up`: the `code_executor` tool is off by
|
||||
# default (removed from DEFAULT_CHAT_TOOLS) and must be enabled per-agent in
|
||||
# the agent tool picker, so a plain `up` does not start this service.
|
||||
# In-process kernels, no Docker socket, no published host port. On the
|
||||
# internal-only sandbox-net plus `default` (for internet egress); deliberately
|
||||
# NOT on data-net, so sandboxed code cannot reach the unauthenticated redis
|
||||
# broker (Celery task injection -> RCE) or postgres. Do NOT add
|
||||
# `env_file: ../.env` here -- the runner needs no app secrets. For untrusted
|
||||
# multi-tenant workloads use a per-session VM via SANDBOX_BACKEND=daytona
|
||||
# instead.
|
||||
docsgpt-sandbox:
|
||||
build: ./sandbox
|
||||
profiles: ["sandbox"]
|
||||
mem_limit: ${SANDBOX_MEMORY:-1g}
|
||||
cpus: ${SANDBOX_CPUS:-1.0}
|
||||
pids_limit: 256
|
||||
read_only: true
|
||||
environment:
|
||||
# Keep Jupyter's runtime/connection files on the writable tmpfs.
|
||||
- JUPYTER_RUNTIME_DIR=/tmp/jupyter-runtime
|
||||
- JUPYTER_DATA_DIR=/tmp/jupyter-data
|
||||
tmpfs:
|
||||
- /tmp
|
||||
networks:
|
||||
# Reachable by backend/worker over the internal sandbox-net; internet
|
||||
# egress (runtime pip install, etc.) via the dedicated sandbox-egress net.
|
||||
# Deliberately NOT on `default`: the egress overlay cuts internet by
|
||||
# flipping sandbox-egress to internal, without severing the control path.
|
||||
- sandbox-net
|
||||
- sandbox-egress
|
||||
# NOTE: sandboxed code execution (the `code_executor` / `artifact_generator`
|
||||
# tools) is an OPT-IN feature and is intentionally NOT part of this default
|
||||
# stack. To run it, layer the sandbox overlay on top of this file:
|
||||
# docker compose -f docker-compose-azure.yaml \
|
||||
# -f optional/docker-compose.optional.sandbox.yaml up
|
||||
# See deployment/sandbox/README.md.
|
||||
|
||||
redis:
|
||||
image: redis:6-alpine
|
||||
# Data plane only: kept off the sandbox's networks (a reachable, unauthed
|
||||
# broker = Celery task injection -> worker RCE).
|
||||
networks:
|
||||
- data-net
|
||||
ports:
|
||||
# Loopback only: the published port must NOT be reachable from the sandbox
|
||||
# via the host/bridge gateway. Host-local dev tools still use localhost.
|
||||
- 127.0.0.1:6379:6379
|
||||
- 6379:6379
|
||||
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
# Data plane only (see redis): kept off the sandbox's networks.
|
||||
networks:
|
||||
- data-net
|
||||
environment:
|
||||
- POSTGRES_USER=docsgpt
|
||||
- POSTGRES_PASSWORD=docsgpt
|
||||
- POSTGRES_DB=docsgpt
|
||||
ports:
|
||||
# Loopback only (see redis): not reachable from the sandbox via the gateway.
|
||||
- "127.0.0.1:5432:5432"
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
@@ -132,21 +76,5 @@ services:
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
networks:
|
||||
# Control plane between backend/worker and the code-exec runner. internal:true
|
||||
# => no internet route on this net; the runner egresses via sandbox-egress.
|
||||
sandbox-net:
|
||||
internal: true
|
||||
# The runner's outbound internet route (runtime pip install, etc.). Kept
|
||||
# internet-facing so the base stack works out of the box;
|
||||
# deployment/optional/docker-compose.optional.sandbox-egress.yaml flips it to
|
||||
# internal:true and forces egress through a deny-private proxy for full SSRF
|
||||
# containment (blocking RFC1918 / link-local / cloud-metadata).
|
||||
sandbox-egress: {}
|
||||
# Internal data plane: redis/postgres + backend/worker. The sandbox is NOT
|
||||
# attached, so sandboxed code cannot reach the broker or database.
|
||||
data-net:
|
||||
internal: true
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
@@ -23,21 +23,8 @@ services:
|
||||
- CELERY_RESULT_BACKEND=redis://redis:6379/1
|
||||
- CACHE_REDIS_URL=redis://redis:6379/2
|
||||
- POSTGRES_URI=postgresql://docsgpt:docsgpt@postgres:5432/docsgpt
|
||||
# Code-execution runner reached over HTTP + WebSocket (no docker socket).
|
||||
- SANDBOX_GATEWAY_URL=http://docsgpt-sandbox:8888
|
||||
# Env-scrubbing kernelspec the docsgpt-sandbox image installs.
|
||||
- SANDBOX_KERNEL_NAME=docsgpt-python
|
||||
ports:
|
||||
- "7091:7091"
|
||||
networks:
|
||||
# `default` reaches the internet (LLM APIs) + frontend/host; `data-net`
|
||||
# reaches redis/postgres; `sandbox-net` reaches the code-exec runner over an
|
||||
# internal-only net (so the runner needs no shared internet bridge to be
|
||||
# reachable, and the egress overlay can cut its internet without severing
|
||||
# this control path).
|
||||
- default
|
||||
- data-net
|
||||
- sandbox-net
|
||||
volumes:
|
||||
- ../application/indexes:/app/indexes
|
||||
- ../application/inputs:/app/inputs
|
||||
@@ -62,14 +49,6 @@ services:
|
||||
- API_URL=http://backend:7091
|
||||
- CACHE_REDIS_URL=redis://redis:6379/2
|
||||
- POSTGRES_URI=postgresql://docsgpt:docsgpt@postgres:5432/docsgpt
|
||||
- SANDBOX_GATEWAY_URL=http://docsgpt-sandbox:8888
|
||||
- SANDBOX_KERNEL_NAME=docsgpt-python
|
||||
networks:
|
||||
# See backend: control-plane access to the code-exec runner over sandbox-net
|
||||
# (workflow code nodes run in the worker).
|
||||
- default
|
||||
- data-net
|
||||
- sandbox-net
|
||||
volumes:
|
||||
- ../application/indexes:/app/indexes
|
||||
- ../application/inputs:/app/inputs
|
||||
@@ -80,64 +59,26 @@ services:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
|
||||
# Opt-in code-execution runner (Jupyter Kernel Gateway). Started ONLY with
|
||||
# `docker compose --profile sandbox up`: the `code_executor` tool is off by
|
||||
# default (removed from DEFAULT_CHAT_TOOLS) and must be enabled per-agent in
|
||||
# the agent tool picker, so a plain `up` does not start this service.
|
||||
# In-process kernels, no Docker socket, no published host port. On the
|
||||
# internal-only sandbox-net plus `default` (for internet egress); deliberately
|
||||
# NOT on data-net, so sandboxed code cannot reach the unauthenticated redis
|
||||
# broker (Celery task injection -> RCE) or postgres. Do NOT add
|
||||
# `env_file: ../.env` here -- the runner needs no app secrets. For untrusted
|
||||
# multi-tenant workloads use a per-session VM via SANDBOX_BACKEND=daytona
|
||||
# instead.
|
||||
docsgpt-sandbox:
|
||||
# Built from the repo (no published arc53/docsgpt-sandbox image exists yet); the
|
||||
# hub compose ships alongside ./sandbox, so this stays self-contained and the
|
||||
# stack always starts. Switch to a published image once CI builds/pushes one.
|
||||
build: ./sandbox
|
||||
profiles: ["sandbox"]
|
||||
mem_limit: ${SANDBOX_MEMORY:-1g}
|
||||
cpus: ${SANDBOX_CPUS:-1.0}
|
||||
pids_limit: 256
|
||||
read_only: true
|
||||
environment:
|
||||
# Keep Jupyter's runtime/connection files on the writable tmpfs.
|
||||
- JUPYTER_RUNTIME_DIR=/tmp/jupyter-runtime
|
||||
- JUPYTER_DATA_DIR=/tmp/jupyter-data
|
||||
tmpfs:
|
||||
- /tmp
|
||||
networks:
|
||||
# Reachable by backend/worker over the internal sandbox-net; internet
|
||||
# egress (runtime pip install, etc.) via the dedicated sandbox-egress net.
|
||||
# Deliberately NOT on `default`: the egress overlay cuts internet by
|
||||
# flipping sandbox-egress to internal, without severing the control path.
|
||||
- sandbox-net
|
||||
- sandbox-egress
|
||||
# NOTE: sandboxed code execution (the `code_executor` / `artifact_generator`
|
||||
# tools) is an OPT-IN feature and is intentionally NOT part of this default
|
||||
# stack. To run it, layer the sandbox overlay on top of this file:
|
||||
# docker compose -f docker-compose-hub.yaml \
|
||||
# -f optional/docker-compose.optional.sandbox.yaml up
|
||||
# See deployment/sandbox/README.md.
|
||||
|
||||
redis:
|
||||
image: redis:6-alpine
|
||||
# Data plane only: kept off the sandbox's networks (a reachable, unauthed
|
||||
# broker = Celery task injection -> worker RCE).
|
||||
networks:
|
||||
- data-net
|
||||
ports:
|
||||
# Loopback only: the published port must NOT be reachable from the sandbox
|
||||
# via the host/bridge gateway. Host-local dev tools still use localhost.
|
||||
- 127.0.0.1:6379:6379
|
||||
- 6379:6379
|
||||
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
# Data plane only (see redis): kept off the sandbox's networks.
|
||||
networks:
|
||||
- data-net
|
||||
environment:
|
||||
- POSTGRES_USER=docsgpt
|
||||
- POSTGRES_PASSWORD=docsgpt
|
||||
- POSTGRES_DB=docsgpt
|
||||
ports:
|
||||
# Loopback only (see redis): not reachable from the sandbox via the gateway.
|
||||
- "127.0.0.1:5432:5432"
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
@@ -146,21 +87,5 @@ services:
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
networks:
|
||||
# Control plane between backend/worker and the code-exec runner. internal:true
|
||||
# => no internet route on this net; the runner egresses via sandbox-egress.
|
||||
sandbox-net:
|
||||
internal: true
|
||||
# The runner's outbound internet route (runtime pip install, etc.). Kept
|
||||
# internet-facing so the base stack works out of the box;
|
||||
# deployment/optional/docker-compose.optional.sandbox-egress.yaml flips it to
|
||||
# internal:true and forces egress through a deny-private proxy for full SSRF
|
||||
# containment (blocking RFC1918 / link-local / cloud-metadata).
|
||||
sandbox-egress: {}
|
||||
# Internal data plane: redis/postgres + backend/worker. The sandbox is NOT
|
||||
# attached, so sandboxed code cannot reach the broker or database.
|
||||
data-net:
|
||||
internal: true
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
@@ -24,23 +24,8 @@ services:
|
||||
- CELERY_RESULT_BACKEND=redis://redis:6379/1
|
||||
- CACHE_REDIS_URL=redis://redis:6379/2
|
||||
- POSTGRES_URI=postgresql://docsgpt:docsgpt@postgres:5432/docsgpt
|
||||
# Code-execution runner reached over HTTP + WebSocket (no docker socket).
|
||||
- SANDBOX_GATEWAY_URL=http://docsgpt-sandbox:8888
|
||||
# Select the runner's env-scrubbing kernelspec (distinct name; never
|
||||
# shadowed by the stock "python3" spec). Must match the kernel the
|
||||
# docsgpt-sandbox image installs.
|
||||
- SANDBOX_KERNEL_NAME=docsgpt-python
|
||||
ports:
|
||||
- "7091:7091"
|
||||
networks:
|
||||
# `default` reaches the internet (LLM APIs) + frontend/host; `data-net`
|
||||
# reaches redis/postgres; `sandbox-net` reaches the code-exec runner over an
|
||||
# internal-only net (so the runner needs no shared internet bridge to be
|
||||
# reachable, and the egress overlay can cut its internet without severing
|
||||
# this control path).
|
||||
- default
|
||||
- data-net
|
||||
- sandbox-net
|
||||
volumes:
|
||||
- ../application/indexes:/app/indexes
|
||||
- ../application/inputs:/app/inputs
|
||||
@@ -56,15 +41,8 @@ services:
|
||||
build: ../application
|
||||
# Consumes the default queue AND the dedicated `parsing` queue (read_document /
|
||||
# parse_document). Without `parsing` here the read_document await never resolves.
|
||||
# For heavy/OCR parsing run a separate worker with `-Q parsing` (see
|
||||
# deployment/sandbox/README.md).
|
||||
# For heavy/OCR parsing run a separate worker with `-Q parsing`.
|
||||
command: celery -A application.app.celery worker -l INFO -B -Q docsgpt,parsing
|
||||
networks:
|
||||
# See backend: control-plane access to the code-exec runner over sandbox-net
|
||||
# (workflow code nodes run in the worker).
|
||||
- default
|
||||
- data-net
|
||||
- sandbox-net
|
||||
env_file:
|
||||
- ../.env
|
||||
environment:
|
||||
@@ -74,9 +52,6 @@ services:
|
||||
- API_URL=http://backend:7091
|
||||
- CACHE_REDIS_URL=redis://redis:6379/2
|
||||
- POSTGRES_URI=postgresql://docsgpt:docsgpt@postgres:5432/docsgpt
|
||||
- SANDBOX_GATEWAY_URL=http://docsgpt-sandbox:8888
|
||||
# Env-scrubbing kernelspec selected by name (see backend service).
|
||||
- SANDBOX_KERNEL_NAME=docsgpt-python
|
||||
volumes:
|
||||
- ../application/indexes:/app/indexes
|
||||
- ../application/inputs:/app/inputs
|
||||
@@ -87,69 +62,19 @@ services:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
|
||||
# Opt-in code-execution runner (Jupyter Kernel Gateway). Started ONLY with
|
||||
# `docker compose --profile sandbox up`: the `code_executor` tool is off by
|
||||
# default (removed from DEFAULT_CHAT_TOOLS) and must be enabled per-agent in
|
||||
# the agent tool picker, so a plain `up` does not start this service.
|
||||
# Sessions are in-process kernels, never child containers; the Docker socket
|
||||
# is NOT mounted. On an internal-only network — no host port is published, so
|
||||
# the runner is reachable only from backend/worker, not from the
|
||||
# host/internet. Egress/SSRF blocks, the gVisor `runsc` runtime, and seccomp
|
||||
# profile come in the hardening slice.
|
||||
#
|
||||
# SINGLE TRUST DOMAIN: all sessions share this one container/uid and are
|
||||
# isolated by working directory only (per-session cwd) — not by a kernel/OS
|
||||
# boundary. The custom kernelspec scrubs secrets from the kernel env, but
|
||||
# sibling workspaces are readable under the shared uid and kernels share one
|
||||
# address space. Do NOT add `env_file: ../.env` here (the runner needs no app
|
||||
# secrets). For cross-tenant / untrusted multi-tenant workloads use a
|
||||
# per-session VM via SANDBOX_BACKEND=daytona instead.
|
||||
docsgpt-sandbox:
|
||||
build: ./sandbox
|
||||
profiles: ["sandbox"]
|
||||
mem_limit: ${SANDBOX_MEMORY:-1g}
|
||||
cpus: ${SANDBOX_CPUS:-1.0}
|
||||
pids_limit: 256
|
||||
read_only: true
|
||||
environment:
|
||||
# Keep Jupyter's runtime/connection files on the writable tmpfs.
|
||||
- JUPYTER_RUNTIME_DIR=/tmp/jupyter-runtime
|
||||
- JUPYTER_DATA_DIR=/tmp/jupyter-data
|
||||
tmpfs:
|
||||
# Per-session workspaces (/tmp/docsgpt-sandbox/<session_id>) and Jupyter
|
||||
# runtime files live on tmpfs; the root FS is read-only everywhere else.
|
||||
- /tmp
|
||||
networks:
|
||||
# Reachable by backend/worker over the internal sandbox-net; internet
|
||||
# egress (runtime pip install, etc.) via the dedicated sandbox-egress net.
|
||||
# Deliberately NOT on `default`: the egress overlay cuts internet by
|
||||
# flipping sandbox-egress to internal, without severing the control path.
|
||||
- sandbox-net
|
||||
- sandbox-egress
|
||||
|
||||
redis:
|
||||
image: redis:6-alpine
|
||||
# Data plane only: NOT reachable from the code-exec sandbox (which has no
|
||||
# auth on this broker, so a reachable Redis = Celery task injection -> RCE).
|
||||
networks:
|
||||
- data-net
|
||||
ports:
|
||||
# Loopback only: the published port must NOT be reachable from the sandbox
|
||||
# via the host/bridge gateway. Host-local dev tools still use localhost.
|
||||
- 127.0.0.1:6379:6379
|
||||
- 6379:6379
|
||||
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
# Data plane only (see redis): kept off the sandbox's networks.
|
||||
networks:
|
||||
- data-net
|
||||
environment:
|
||||
- POSTGRES_USER=docsgpt
|
||||
- POSTGRES_PASSWORD=docsgpt
|
||||
- POSTGRES_DB=docsgpt
|
||||
ports:
|
||||
# Loopback only (see redis): not reachable from the sandbox via the gateway.
|
||||
- "127.0.0.1:5432:5432"
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
@@ -158,24 +83,5 @@ services:
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
networks:
|
||||
# Control plane between backend/worker and the code-exec runner. internal:true
|
||||
# => no internet route on this net; the runner egresses via sandbox-egress.
|
||||
sandbox-net:
|
||||
internal: true
|
||||
# The runner's outbound internet route (runtime pip install, etc.). Kept
|
||||
# internet-facing so the base stack works out of the box;
|
||||
# deployment/optional/docker-compose.optional.sandbox-egress.yaml flips it to
|
||||
# internal:true and forces egress through a deny-private proxy for full SSRF
|
||||
# containment (blocking RFC1918 / link-local / cloud-metadata).
|
||||
sandbox-egress: {}
|
||||
# Internal-only data plane. redis (broker/cache) and postgres live here with
|
||||
# backend/worker, but the code-exec sandbox is NOT attached, so arbitrary
|
||||
# sandboxed code cannot reach the unauthenticated broker (Celery task
|
||||
# injection -> worker RCE) or the database.
|
||||
data-net:
|
||||
internal: true
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
|
||||
@@ -57,14 +57,6 @@ spec:
|
||||
value: "false"
|
||||
- name: AUTO_CREATE_DB
|
||||
value: "false"
|
||||
# Reach the always-on code-execution runner over HTTP + WebSocket.
|
||||
- name: SANDBOX_GATEWAY_URL
|
||||
value: "http://docsgpt-sandbox:8888"
|
||||
# Select the runner's env-scrubbing kernelspec by its distinct name so
|
||||
# it is never shadowed by the stock "python3" spec. MUST be set here:
|
||||
# the runner only ships the kernelspec; the app (this pod) chooses it.
|
||||
- name: SANDBOX_KERNEL_NAME
|
||||
value: "docsgpt-python"
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
@@ -121,14 +113,6 @@ spec:
|
||||
value: "false"
|
||||
- name: AUTO_CREATE_DB
|
||||
value: "false"
|
||||
# Reach the always-on code-execution runner over HTTP + WebSocket.
|
||||
- name: SANDBOX_GATEWAY_URL
|
||||
value: "http://docsgpt-sandbox:8888"
|
||||
# Select the runner's env-scrubbing kernelspec by its distinct name so
|
||||
# it is never shadowed by the stock "python3" spec. MUST be set here:
|
||||
# the runner only ships the kernelspec; the app (this pod) chooses it.
|
||||
- name: SANDBOX_KERNEL_NAME
|
||||
value: "docsgpt-python"
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
|
||||
@@ -1,7 +1,25 @@
|
||||
# docsgpt-sandbox runner (Jupyter Kernel Gateway). Sessions are in-process
|
||||
# kernels, never child containers; the Docker socket is NOT mounted. Network
|
||||
# egress is constrained by the NetworkPolicy under
|
||||
# deployment/k8s/network-policies/sandbox-egress-policy.yaml -- apply both.
|
||||
# docsgpt-sandbox runner (Jupyter Kernel Gateway). OPT-IN: this manifest and its
|
||||
# NetworkPolicy are NOT in kustomization.yaml; apply them only when enabling
|
||||
# code execution. Sessions are in-process kernels, never child containers; the
|
||||
# Docker socket is NOT mounted. Network egress is constrained by the NetworkPolicy
|
||||
# under deployment/k8s/network-policies/sandbox-egress-policy.yaml -- apply both.
|
||||
#
|
||||
# REQUIRED before enabling: create the shared gateway-token Secret and add the
|
||||
# runner env to the app pods (docsgpt-api / docsgpt-worker), which the default
|
||||
# docsgpt-deploy.yaml intentionally omits (sandbox is off by default):
|
||||
# kubectl create secret generic docsgpt-sandbox-gateway \
|
||||
# --from-literal=token="$(openssl rand -hex 32)"
|
||||
# # on docsgpt-api and docsgpt-worker containers, add:
|
||||
# # - name: SANDBOX_GATEWAY_URL
|
||||
# # value: "http://docsgpt-sandbox:8888"
|
||||
# # - name: SANDBOX_KERNEL_NAME
|
||||
# # value: "docsgpt-python"
|
||||
# # - name: SANDBOX_GATEWAY_AUTH_TOKEN
|
||||
# # valueFrom: { secretKeyRef: { name: docsgpt-sandbox-gateway, key: token } }
|
||||
# The gateway authenticates every HTTP + WebSocket request with the token, so
|
||||
# kernel code cannot reach the gateway control API over loopback (enumerate/kill
|
||||
# sibling kernels or bypass the session cap). The gateway fails closed if the
|
||||
# token is unset.
|
||||
#
|
||||
# On Linux prod, schedule this onto a gVisor `runsc` RuntimeClass for kernel
|
||||
# isolation (uncomment `runtimeClassName` once the node has it installed).
|
||||
@@ -52,6 +70,13 @@ spec:
|
||||
drop:
|
||||
- ALL
|
||||
env:
|
||||
# Shared gateway token; the launcher fails closed if it is unset.
|
||||
# Must match SANDBOX_GATEWAY_AUTH_TOKEN on the app pods.
|
||||
- name: SANDBOX_GATEWAY_AUTH_TOKEN
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: docsgpt-sandbox-gateway
|
||||
key: token
|
||||
- name: JUPYTER_RUNTIME_DIR
|
||||
value: /tmp/jupyter-runtime
|
||||
- name: JUPYTER_DATA_DIR
|
||||
|
||||
@@ -7,9 +7,10 @@
|
||||
# metadata ranges and ALLOWS the public internet.
|
||||
#
|
||||
# HOW THIS OVERLAY WORKS
|
||||
# The base stack puts docsgpt-sandbox on two networks: the internal `sandbox-net`
|
||||
# (control plane to backend/worker) and `sandbox-egress` (its internet route).
|
||||
# This overlay flips `sandbox-egress` to `internal: true`, which removes the
|
||||
# The sandbox overlay (docker-compose.optional.sandbox.yaml) puts docsgpt-sandbox
|
||||
# on two networks: the internal `sandbox-net` (control plane to backend/worker)
|
||||
# and `sandbox-egress` (its internet route). Layer THIS overlay on top of it to
|
||||
# flip `sandbox-egress` to `internal: true`, which removes the
|
||||
# runner's direct route to the internet / host / RFC1918 / metadata entirely --
|
||||
# so raw sockets in arbitrary sandbox code have no route OFF the host except the
|
||||
# deny-private proxy. The proxy is the ONLY container with an internet route
|
||||
@@ -50,7 +51,7 @@
|
||||
# `{{.NetworkSettings.Networks.docsgpt-oss_sandbox-egress.IPAddress}}` form
|
||||
# never parsed. Both IPs are read on sandbox-net so source+dest match the
|
||||
# shared path:
|
||||
# CF="-f deployment/docker-compose.yaml -f deployment/optional/docker-compose.optional.sandbox-egress.yaml --profile sandbox"
|
||||
# CF="-f deployment/docker-compose.yaml -f deployment/optional/docker-compose.optional.sandbox.yaml -f deployment/optional/docker-compose.optional.sandbox-egress.yaml"
|
||||
# NET=docsgpt-oss_sandbox-net
|
||||
# SBX=$(docker inspect -f "{{(index .NetworkSettings.Networks \"$NET\").IPAddress}}" "$(docker compose $CF ps -q docsgpt-sandbox)")
|
||||
# BE=$( docker inspect -f "{{(index .NetworkSettings.Networks \"$NET\").IPAddress}}" "$(docker compose $CF ps -q backend)")
|
||||
@@ -74,11 +75,10 @@
|
||||
# This does NOT stop runner->backend/worker on sandbox-net -- pair it with
|
||||
# backend auth and/or approach (1).
|
||||
#
|
||||
# Apply alongside the base stack:
|
||||
# Apply on top of the base stack AND the sandbox overlay (order matters):
|
||||
# docker compose -f deployment/docker-compose.yaml \
|
||||
# -f deployment/optional/docker-compose.optional.sandbox.yaml \
|
||||
# -f deployment/optional/docker-compose.optional.sandbox-egress.yaml up -d
|
||||
# (also works layered on docker-compose-hub.yaml / docker-compose-azure.yaml,
|
||||
# which share the same service/network names).
|
||||
#
|
||||
# NOTE: because `sandbox-egress` is internal here, non-HTTP raw egress TO THE
|
||||
# INTERNET / HOST / RFC1918 / METADATA is blocked at L3 regardless of what the
|
||||
@@ -102,14 +102,10 @@ services:
|
||||
- sandbox-egress-out # the ONLY container with an internet route
|
||||
|
||||
docsgpt-sandbox:
|
||||
# Match the base service's profile so this overlay fragment merges onto the
|
||||
# profiled base and the runner still starts only under `--profile sandbox`
|
||||
# (Compose unions service fragments across -f files; keeping the profile
|
||||
# here means a bare `up` never starts the runner via this overlay either).
|
||||
profiles: ["sandbox"]
|
||||
# No `networks:` override here on purpose: the base already attaches the
|
||||
# runner to sandbox-net + sandbox-egress. Flipping sandbox-egress to internal
|
||||
# (below) removes its direct internet route; its only way out is this proxy.
|
||||
# No `networks:` override here on purpose: the sandbox overlay already
|
||||
# attaches the runner to sandbox-net + sandbox-egress. Flipping sandbox-egress
|
||||
# to internal (below) removes its direct internet route; its only way out is
|
||||
# this proxy.
|
||||
environment:
|
||||
- HTTP_PROXY=http://sandbox-egress-proxy:8080
|
||||
- HTTPS_PROXY=http://sandbox-egress-proxy:8080
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
# Optional code-execution sandbox overlay.
|
||||
#
|
||||
# Sandboxed code execution (the `code_executor` / `artifact_generator` tools) is
|
||||
# an OPT-IN feature and is NOT part of the default stack. This overlay adds the
|
||||
# `docsgpt-sandbox` runner (a Jupyter Kernel Gateway; sessions are in-process
|
||||
# kernels, never child containers, and the Docker socket is NOT mounted) and
|
||||
# wires the app to it.
|
||||
#
|
||||
# Apply on top of the base stack (order matters -- base first):
|
||||
# docker compose -f deployment/docker-compose.yaml \
|
||||
# -f deployment/optional/docker-compose.optional.sandbox.yaml up
|
||||
#
|
||||
# REQUIRED: set a shared gateway token before starting. The runner's gateway
|
||||
# authenticates every HTTP + WebSocket request with it (so kernel code cannot
|
||||
# reach the gateway control API over loopback -- enumerate/kill sibling kernels
|
||||
# or bypass the session cap), and the app sends it as `Authorization: token`.
|
||||
# Compose fails to start if it is unset. Put it in `deployment/.env` or export it:
|
||||
# export SANDBOX_GATEWAY_AUTH_TOKEN=$(openssl rand -hex 32)
|
||||
#
|
||||
# SECURITY POSTURE (single trust domain): all sessions share this one
|
||||
# container/uid and are isolated by working directory only -- sibling workspaces
|
||||
# are readable and kernels share one address space. The kernelspec scrubs secrets
|
||||
# from the kernel env, and the runner sits on the internal `sandbox-net` +
|
||||
# `sandbox-egress` only (NOT the `default` net), so it has no Docker-DNS route to
|
||||
# redis/postgres. Two residual gaps remain, exactly as documented in the egress
|
||||
# overlay: (a) internet/host/RFC1918/metadata egress is open until you ALSO layer
|
||||
# docker-compose.optional.sandbox-egress.yaml; (b) the runner shares sandbox-net
|
||||
# with backend/worker (its control path), so on `AUTH_TYPE=none` it can reach the
|
||||
# API unauthenticated -- run real auth and/or the egress overlay's host-firewall
|
||||
# DROP. For untrusted multi-tenant workloads use the Daytona backend
|
||||
# (SANDBOX_BACKEND=daytona, a per-session VM) or the Kubernetes manifests, whose
|
||||
# NetworkPolicy blocks the internal path too.
|
||||
|
||||
services:
|
||||
backend:
|
||||
environment:
|
||||
# Code-execution runner reached over HTTP + WebSocket (no docker socket).
|
||||
- SANDBOX_GATEWAY_URL=http://docsgpt-sandbox:8888
|
||||
# Select the runner's env-scrubbing kernelspec (distinct name; never
|
||||
# shadowed by the stock "python3" spec).
|
||||
- SANDBOX_KERNEL_NAME=docsgpt-python
|
||||
# Shared secret the app sends as `Authorization: token <...>`. Must match
|
||||
# the runner's token below; compose errors if it is unset.
|
||||
- SANDBOX_GATEWAY_AUTH_TOKEN=${SANDBOX_GATEWAY_AUTH_TOKEN:?set SANDBOX_GATEWAY_AUTH_TOKEN to a shared gateway token}
|
||||
networks:
|
||||
- default
|
||||
- sandbox-net
|
||||
|
||||
worker:
|
||||
environment:
|
||||
- SANDBOX_GATEWAY_URL=http://docsgpt-sandbox:8888
|
||||
- SANDBOX_KERNEL_NAME=docsgpt-python
|
||||
- SANDBOX_GATEWAY_AUTH_TOKEN=${SANDBOX_GATEWAY_AUTH_TOKEN:?set SANDBOX_GATEWAY_AUTH_TOKEN to a shared gateway token}
|
||||
networks:
|
||||
- default
|
||||
- sandbox-net
|
||||
|
||||
docsgpt-sandbox:
|
||||
build: ./sandbox
|
||||
mem_limit: ${SANDBOX_MEMORY:-1g}
|
||||
cpus: ${SANDBOX_CPUS:-1.0}
|
||||
pids_limit: 256
|
||||
read_only: true
|
||||
environment:
|
||||
# The gateway REQUIRES this and fails closed if unset (see gateway-launch.sh).
|
||||
# Do NOT add `env_file: ../.env` here -- the runner needs no other app secret.
|
||||
- SANDBOX_GATEWAY_AUTH_TOKEN=${SANDBOX_GATEWAY_AUTH_TOKEN:?set SANDBOX_GATEWAY_AUTH_TOKEN to a shared gateway token}
|
||||
# Keep Jupyter's runtime/connection files on the writable tmpfs.
|
||||
- JUPYTER_RUNTIME_DIR=/tmp/jupyter-runtime
|
||||
- JUPYTER_DATA_DIR=/tmp/jupyter-data
|
||||
tmpfs:
|
||||
# Per-session workspaces (/tmp/docsgpt-sandbox/<session_id>) and Jupyter
|
||||
# runtime files live on tmpfs; the root FS is read-only everywhere else.
|
||||
- /tmp
|
||||
networks:
|
||||
# Reachable by backend/worker over the internal sandbox-net; internet egress
|
||||
# (runtime pip install, etc.) via the dedicated sandbox-egress net. NOT on
|
||||
# `default`: the egress overlay cuts internet by flipping sandbox-egress to
|
||||
# internal, without severing the control path.
|
||||
- sandbox-net
|
||||
- sandbox-egress
|
||||
|
||||
networks:
|
||||
# Control plane between backend/worker and the runner. internal:true => no
|
||||
# internet route on this net; the runner egresses via sandbox-egress.
|
||||
sandbox-net:
|
||||
internal: true
|
||||
# The runner's outbound internet route. Internet-facing by default so runtime
|
||||
# pip install works; docker-compose.optional.sandbox-egress.yaml flips it to
|
||||
# internal and forces egress through a deny-private proxy for SSRF containment.
|
||||
sandbox-egress: {}
|
||||
@@ -56,6 +56,8 @@ RUN if [ "$INSTALL_DOCLING" = "true" ]; then \
|
||||
# runner image itself should stay free of app secrets it has no use for.
|
||||
COPY kernel-launch.sh /opt/docsgpt/kernel-launch.sh
|
||||
RUN chmod 0555 /opt/docsgpt/kernel-launch.sh
|
||||
COPY gateway-launch.sh /opt/docsgpt/gateway-launch.sh
|
||||
RUN chmod 0555 /opt/docsgpt/gateway-launch.sh
|
||||
COPY kernels/docsgpt-python/kernel.json /usr/local/share/jupyter/kernels/docsgpt-python/kernel.json
|
||||
|
||||
# Numeric UID (not the name) so a kubelet with `runAsNonRoot: true` can verify
|
||||
@@ -66,12 +68,8 @@ WORKDIR /home/sandbox
|
||||
|
||||
EXPOSE 8888
|
||||
|
||||
# ip=0.0.0.0 so the backend can reach it over the internal compose network.
|
||||
# allow_origin is intentionally NOT set to "*" — publishing port 8888 requires
|
||||
# setting SANDBOX_GATEWAY_AUTH_TOKEN (see README). limit_rate=False raises the
|
||||
# iopub data-rate cap so large get_file base64 payloads are not truncated; the
|
||||
# get_file integrity check still guards against truncation if this is ever off.
|
||||
CMD ["jupyter", "kernelgateway", \
|
||||
"--KernelGatewayApp.ip=0.0.0.0", \
|
||||
"--KernelGatewayApp.port=8888", \
|
||||
"--ZMQChannelsWebsocketConnection.limit_rate=False"]
|
||||
# The launcher REQUIRES SANDBOX_GATEWAY_AUTH_TOKEN and fails closed if it is
|
||||
# unset: the gateway control API is reachable from kernel code over loopback, so
|
||||
# it must never run unauthenticated (see gateway-launch.sh). The token is shared
|
||||
# with the app and is scrubbed from the kernel env by kernel-launch.sh.
|
||||
CMD ["/opt/docsgpt/gateway-launch.sh"]
|
||||
@@ -7,28 +7,34 @@ never a child container; the Docker socket is **not** mounted.
|
||||
|
||||
## Enabling code execution (opt-in)
|
||||
|
||||
The runner is **opt-in**. `code_executor` is no longer a default chat tool (it
|
||||
was removed from `DEFAULT_CHAT_TOOLS`), so a plain `docker compose up` does
|
||||
**not** start `docsgpt-sandbox`. Start it explicitly with the `sandbox`
|
||||
profile:
|
||||
The runner is **opt-in**. Neither `code_executor` nor `artifact_generator` is a
|
||||
default chat tool (both were removed from `DEFAULT_CHAT_TOOLS`), and the runner
|
||||
is **not** part of the base compose stack — a plain `docker compose up` does
|
||||
**not** start `docsgpt-sandbox`. Enable it by layering the sandbox overlay and
|
||||
setting a shared gateway token:
|
||||
|
||||
```bash
|
||||
docker compose -f deployment/docker-compose.yaml --profile sandbox up
|
||||
export SANDBOX_GATEWAY_AUTH_TOKEN=$(openssl rand -hex 32)
|
||||
docker compose \
|
||||
-f deployment/docker-compose.yaml \
|
||||
-f deployment/optional/docker-compose.optional.sandbox.yaml up
|
||||
```
|
||||
|
||||
With the egress-firewall overlay (see *Network egress / SSRF* below):
|
||||
The token is **required** — the gateway fails closed if it is unset (see
|
||||
*Gateway authentication* below). Add the egress-firewall overlay for SSRF
|
||||
containment (see *Network egress / SSRF*):
|
||||
|
||||
```bash
|
||||
docker compose \
|
||||
-f deployment/docker-compose.yaml \
|
||||
-f deployment/optional/docker-compose.optional.sandbox-egress.yaml \
|
||||
--profile sandbox up
|
||||
-f deployment/optional/docker-compose.optional.sandbox.yaml \
|
||||
-f deployment/optional/docker-compose.optional.sandbox-egress.yaml up
|
||||
```
|
||||
|
||||
Then enable `code_executor` **per-agent** in the agent tool picker — it is not a
|
||||
default chat tool. Agents without it never call the runner, and the
|
||||
backend/worker degrade gracefully when the runner is absent. The `-hub` and
|
||||
`-azure` compose variants gate the runner behind the same `sandbox` profile.
|
||||
Then enable `code_executor` / `artifact_generator` **per-agent** in the agent
|
||||
tool picker. Agents without them never call the runner, and the backend/worker
|
||||
degrade gracefully when the runner is absent. The `-hub` and `-azure` compose
|
||||
variants take the same sandbox overlay.
|
||||
|
||||
## Isolation model
|
||||
|
||||
@@ -70,6 +76,10 @@ Residual gaps (treat all sessions in one runner as mutually trusting):
|
||||
- **In-memory / cross-kernel.** Kernels are child processes of one gateway under
|
||||
one uid; OS-level process isolation is the only boundary, and it is not a
|
||||
sandbox boundary against a determined escape. No gVisor in the base posture.
|
||||
(The gateway's HTTP/WebSocket control API is reachable from kernel code over
|
||||
loopback, but it is **authenticated** — see *Gateway authentication* — and the
|
||||
token is scrubbed from the kernel env, so kernel code cannot drive it to
|
||||
enumerate/kill sibling kernels or spawn kernels past the session cap.)
|
||||
- **Egress.** Outbound is broad by design (so code can `pip install` / call
|
||||
public APIs). Private/link-local/metadata ranges are blocked **only** by the
|
||||
network layer — the k8s NetworkPolicy or a host/cloud firewall (see *Network
|
||||
@@ -88,15 +98,19 @@ Build and run the runner on its own, then point the app at it:
|
||||
|
||||
```bash
|
||||
docker build -t docsgpt-sandbox deployment/sandbox
|
||||
docker run --rm -p 8888:8888 docsgpt-sandbox
|
||||
docker run --rm -p 8888:8888 -e SANDBOX_GATEWAY_AUTH_TOKEN=devtoken docsgpt-sandbox
|
||||
# in the app's .env: SANDBOX_GATEWAY_URL=http://localhost:8888
|
||||
# SANDBOX_GATEWAY_AUTH_TOKEN=devtoken
|
||||
```
|
||||
|
||||
Without Docker (matches the test harness) you can run the gateway directly from
|
||||
a venv that has `jupyter-kernel-gateway` installed:
|
||||
The token is required — the image's entrypoint refuses to start without it (see
|
||||
*Gateway authentication*). Without Docker (matches the test harness) you can run
|
||||
the gateway directly from a venv that has `jupyter-kernel-gateway` installed; set
|
||||
a matching `--KernelGatewayApp.auth_token`:
|
||||
|
||||
```bash
|
||||
jupyter kernelgateway --KernelGatewayApp.ip=0.0.0.0 --KernelGatewayApp.port=8888 \
|
||||
--KernelGatewayApp.auth_token=devtoken \
|
||||
--ZMQChannelsWebsocketConnection.limit_rate=False
|
||||
```
|
||||
|
||||
@@ -115,26 +129,35 @@ copy `kernels/docsgpt-python/kernel.json` (pointing `argv` at a local copy of
|
||||
`kernel-launch.sh`) into a Jupyter data dir on the kernelspec search path and
|
||||
set `SANDBOX_KERNEL_NAME=docsgpt-python` before launching.
|
||||
|
||||
## Exposing the port requires auth
|
||||
## Gateway authentication
|
||||
|
||||
The image does **not** set `--KernelGatewayApp.allow_origin=*`. If you publish
|
||||
port 8888 (e.g. `docker run -p 8888:8888`), set `SANDBOX_GATEWAY_AUTH_TOKEN`
|
||||
and launch the gateway with a matching `--KernelGatewayApp.auth_token` so the
|
||||
runner is not an open arbitrary-code-execution endpoint. In compose the runner
|
||||
stays on the internal-only network with no published port, so no token is
|
||||
required there.
|
||||
The gateway **requires** an auth token and **fails closed** if it is unset — the
|
||||
image's entrypoint (`gateway-launch.sh`) refuses to start an unauthenticated
|
||||
gateway. This matters even on an internal-only network: the gateway and every
|
||||
session kernel share one container, so kernel code can reach the gateway's
|
||||
control API over **loopback** (`http://localhost:8888`). Without auth, that
|
||||
control API would let kernel code enumerate/attach/kill sibling sessions'
|
||||
kernels and spawn kernels without bound (bypassing the app-side session cap).
|
||||
|
||||
Set the same token on the runner and the app via `SANDBOX_GATEWAY_AUTH_TOKEN`
|
||||
(the app sends it as `Authorization: token <...>`; the runner's gateway
|
||||
validates it on every HTTP + WebSocket request). Kernel code cannot read it: the
|
||||
kernelspec launcher scrubs it from the kernel env (see *Isolation model*), so it
|
||||
is present for the gateway process only. The image also does **not** set
|
||||
`--KernelGatewayApp.allow_origin=*`.
|
||||
|
||||
## In docker-compose
|
||||
|
||||
The `docsgpt-sandbox` service is defined in `deployment/docker-compose.yaml` on
|
||||
an internal-only network and is gated behind the `sandbox` Compose profile
|
||||
(opt-in — start it with `docker compose --profile sandbox up`; see *Enabling
|
||||
code execution (opt-in)* above). The backend and worker reach it at
|
||||
`http://docsgpt-sandbox:8888` and select the scrubbing kernel by setting
|
||||
`SANDBOX_KERNEL_NAME=docsgpt-python` (the runner only ships the kernelspec; the
|
||||
app chooses it). The same applies to k8s: `SANDBOX_KERNEL_NAME=docsgpt-python`
|
||||
is set on the `docsgpt-api` and `docsgpt-worker` deployments in
|
||||
`deployment/k8s/deployments/docsgpt-deploy.yaml`.
|
||||
The `docsgpt-sandbox` service lives in the opt-in overlay
|
||||
`deployment/optional/docker-compose.optional.sandbox.yaml` (layered on the base
|
||||
stack; see *Enabling code execution (opt-in)*) on an internal-only network with
|
||||
no published host port. The overlay puts the backend and worker on `sandbox-net`
|
||||
to reach the runner at `http://docsgpt-sandbox:8888`, and sets
|
||||
`SANDBOX_KERNEL_NAME=docsgpt-python` on them (the runner only ships the
|
||||
kernelspec; the app chooses it) plus the shared `SANDBOX_GATEWAY_AUTH_TOKEN`. In
|
||||
k8s these are added to the `docsgpt-api` and `docsgpt-worker` deployments when
|
||||
enabling the opt-in `sandbox-deploy.yaml` (the default `docsgpt-deploy.yaml`
|
||||
omits them); see that manifest's header for the exact env and the token Secret.
|
||||
|
||||
## Artifact rendering on Daytona (snapshot)
|
||||
|
||||
@@ -221,7 +244,7 @@ The hardened container runs **without `NET_ADMIN`**, so it cannot self-apply
|
||||
```
|
||||
|
||||
- **docker-compose** — compose cannot express L3 egress filtering natively. The
|
||||
base stack reaches the runner over an `internal: true` control network
|
||||
sandbox overlay reaches the runner over an `internal: true` control network
|
||||
(`sandbox-net`, no host port) and gives it internet egress on a dedicated
|
||||
`sandbox-egress` bridge — but that bridge does not by itself block the metadata
|
||||
IP or RFC1918. Apply
|
||||
@@ -241,16 +264,20 @@ The hardened container runs **without `NET_ADMIN`**, so it cannot self-apply
|
||||
real authentication (`AUTH_TYPE` != none / a real auth provider) so a reachable
|
||||
API rejects unauthenticated requests — **required** — and/or add a host-firewall
|
||||
`DROP` for runner→backend/worker on `sandbox-net` (see approach (1) in the
|
||||
overlay file's header comment). Note the broker/DB published ports are bound to
|
||||
`127.0.0.1` so the runner cannot reach them via the host gateway either.
|
||||
overlay file's header comment). The runner is not on the `default` network, so
|
||||
it has no Docker-DNS route to redis/postgres; if the broker/DB publish host
|
||||
ports on a cloud VM, also apply the egress overlay (its `internal` flip removes
|
||||
the runner's route to the host gateway / RFC1918) or bind those ports to
|
||||
`127.0.0.1`.
|
||||
|
||||
## Other hardening (deployment-level)
|
||||
|
||||
The gVisor `runsc` runtime (kernel isolation for untrusted code), seccomp
|
||||
profile, read-only root FS, non-root, and cgroup CPU/mem/PID caps (wired from
|
||||
`SANDBOX_MEMORY` / `SANDBOX_CPUS`) are deployment-level concerns. The compose
|
||||
service in `deployment/docker-compose.yaml` already sets `read_only`,
|
||||
`mem_limit`, `cpus`, and `pids_limit`; the k8s `sandbox-deploy.yaml` sets the
|
||||
service in `deployment/optional/docker-compose.optional.sandbox.yaml` already
|
||||
sets `read_only`, `mem_limit`, `cpus`, and `pids_limit`; the k8s
|
||||
`sandbox-deploy.yaml` sets the
|
||||
equivalent `securityContext` + resource limits and has a commented
|
||||
`runtimeClassName: gvisor` to enable on nodes with the `runsc` RuntimeClass
|
||||
installed. These complement — they do not replace — the network egress policy
|
||||
|
||||
Executable
+33
@@ -0,0 +1,33 @@
|
||||
#!/bin/sh
|
||||
# Entrypoint for the docsgpt-sandbox Jupyter Kernel Gateway.
|
||||
#
|
||||
# The gateway and every session kernel share this one container, so LLM-authored
|
||||
# kernel code can reach the gateway's control API over loopback
|
||||
# (http://localhost:8888). WITHOUT authentication that means kernel code could
|
||||
# enumerate/attach/kill sibling sessions' kernels and spawn kernels without bound
|
||||
# (bypassing the app-side session cap). We therefore REQUIRE an auth token and
|
||||
# fail closed if it is missing -- an unauthenticated gateway must never start.
|
||||
#
|
||||
# The token is shared with the app via SANDBOX_GATEWAY_AUTH_TOKEN (the app sends
|
||||
# it as `Authorization: token <...>`). Kernel code cannot read it: kernel-launch.sh
|
||||
# re-execs ipykernel under a scrubbed `env -i` allowlist that excludes the token,
|
||||
# so it is present for the gateway process only.
|
||||
set -eu
|
||||
|
||||
TOKEN="${SANDBOX_GATEWAY_AUTH_TOKEN:-}"
|
||||
if [ -z "$TOKEN" ]; then
|
||||
echo "docsgpt-sandbox: refusing to start an unauthenticated gateway." >&2
|
||||
echo "Set SANDBOX_GATEWAY_AUTH_TOKEN (same value on the app and this container)." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ip=0.0.0.0 so the backend/worker can reach it over the internal sandbox network.
|
||||
# auth_token gates every HTTP + WebSocket request, including loopback ones from
|
||||
# kernel code. limit_rate=False raises the iopub data-rate cap so large get_file
|
||||
# base64 payloads are not truncated (the get_file integrity check still guards
|
||||
# truncation if this is ever off).
|
||||
exec jupyter kernelgateway \
|
||||
--KernelGatewayApp.ip=0.0.0.0 \
|
||||
--KernelGatewayApp.port=8888 \
|
||||
--KernelGatewayApp.auth_token="$TOKEN" \
|
||||
--ZMQChannelsWebsocketConnection.limit_rate=False
|
||||
Regular → Executable
File mode changed.
@@ -13,7 +13,7 @@ DocsGPT can generate documents, run code, and read files for you during a chat o
|
||||
- **Code Executor** runs code in a sandboxed session and turns any files the code writes into downloadable artifacts.
|
||||
- **Read Document** parses an uploaded or produced file (PDF, Word, PowerPoint, and more) into text, markdown, or structured data.
|
||||
|
||||
The first two are enabled by default for new agents. Read Document is available inside workflows.
|
||||
All three are opt-in: enable Artifact and Code Executor per agent in the tool picker (they need a running [sandbox runner](https://github.com/arc53/DocsGPT/tree/main/deployment/sandbox)), and Read Document is available inside workflows. A new agent starts with no tools enabled.
|
||||
|
||||
## Artifacts
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
ARTIFACT_URL_ENVELOPE_MIME,
|
||||
buildPreviewDocument,
|
||||
bytesPreviewModeForMime,
|
||||
displayFilename,
|
||||
@@ -237,14 +238,14 @@ describe('buildPreviewDocument', () => {
|
||||
});
|
||||
|
||||
describe('readPresignedUrlEnvelope', () => {
|
||||
const json = (body: unknown, contentType = 'application/json') =>
|
||||
const envelope = (body: unknown, contentType = ARTIFACT_URL_ENVELOPE_MIME) =>
|
||||
new Response(JSON.stringify(body), {
|
||||
headers: { 'Content-Type': contentType },
|
||||
});
|
||||
|
||||
it('extracts the presigned url from the s3 JSON envelope', async () => {
|
||||
const url = await readPresignedUrlEnvelope(
|
||||
json({ success: true, url: 'https://signed.example/x?sig=1' }),
|
||||
envelope({ success: true, url: 'https://signed.example/x?sig=1' }),
|
||||
);
|
||||
expect(url).toBe('https://signed.example/x?sig=1');
|
||||
});
|
||||
@@ -258,20 +259,36 @@ describe('readPresignedUrlEnvelope', () => {
|
||||
});
|
||||
|
||||
it('returns null for a JSON artifact whose bytes are not the envelope', async () => {
|
||||
const res = json({ foo: 1 });
|
||||
const res = envelope({ foo: 1 }, 'application/json');
|
||||
expect(await readPresignedUrlEnvelope(res)).toBeNull();
|
||||
// clone() was used to peek, so the original body is still consumable.
|
||||
expect(await res.json()).toEqual({ foo: 1 });
|
||||
});
|
||||
|
||||
it('ignores an attacker JSON artifact that mimics the envelope body', async () => {
|
||||
// Under URL_STRATEGY=backend a `data` artifact is streamed with its own
|
||||
// application/json content-type; even with the exact envelope shape it must
|
||||
// NOT be treated as a redirect (open-redirect gadget) — only the vendor
|
||||
// media type marks a real envelope.
|
||||
const res = envelope(
|
||||
{ success: true, url: 'https://attacker.example/phish' },
|
||||
'application/json',
|
||||
);
|
||||
expect(await readPresignedUrlEnvelope(res)).toBeNull();
|
||||
expect(await res.json()).toEqual({
|
||||
success: true,
|
||||
url: 'https://attacker.example/phish',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects a non-http(s) url in the envelope', async () => {
|
||||
expect(
|
||||
await readPresignedUrlEnvelope(
|
||||
json({ success: true, url: 'javascript:alert(1)' }),
|
||||
envelope({ success: true, url: 'javascript:alert(1)' }),
|
||||
),
|
||||
).toBeNull();
|
||||
expect(
|
||||
await readPresignedUrlEnvelope(json({ success: true, url: '' })),
|
||||
await readPresignedUrlEnvelope(envelope({ success: true, url: '' })),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -308,7 +325,7 @@ describe('triggerResponseDownload', () => {
|
||||
it('navigates top-level to the presigned url for the s3 envelope', async () => {
|
||||
const res = new Response(
|
||||
JSON.stringify({ success: true, url: 'https://signed.example/x' }),
|
||||
{ headers: { 'Content-Type': 'application/json' } },
|
||||
{ headers: { 'Content-Type': ARTIFACT_URL_ENVELOPE_MIME } },
|
||||
);
|
||||
expect(await triggerResponseDownload(res, 'f.bin')).toBe(true);
|
||||
// No blob path for the presigned envelope.
|
||||
|
||||
@@ -259,19 +259,29 @@ export function filenameFromContentDisposition(
|
||||
|
||||
/**
|
||||
* Detect the `URL_STRATEGY=s3` presigned-URL envelope and return its `url`, or
|
||||
* null when the response is a byte stream (`URL_STRATEGY=backend`). The download
|
||||
* endpoint returns `{ success: true, url }` as JSON only when asked via
|
||||
* `?disposition=url`; a plain byte stream (even a JSON artifact's own bytes)
|
||||
* carries the file's own content-type and never this exact shape. The response
|
||||
* is `clone()`d before reading so the caller can still consume the original body
|
||||
* as bytes when this is not the envelope. The `url` must be an absolute http(s)
|
||||
* URL to further guard against a JSON artifact coincidentally matching.
|
||||
* null when the response is a byte stream (`URL_STRATEGY=backend`).
|
||||
*
|
||||
* Detection keys off the server-set envelope media type, NOT the body shape:
|
||||
* under the backend strategy the endpoint streams the artifact's own bytes, so a
|
||||
* `data` artifact whose bytes are literally
|
||||
* `{"success":true,"url":"https://attacker.example"}` would otherwise be read as
|
||||
* a redirect target and navigated to (an open-redirect / phishing gadget). Only
|
||||
* the real envelope carries `ARTIFACT_URL_ENVELOPE_MIME` (a stored artifact's own
|
||||
* mime can never be this vendor value, and Content-Type is CORS-safelisted so it
|
||||
* stays readable cross-origin), so attacker-controlled bytes can never be
|
||||
* mistaken for one. The response is `clone()`d before reading so the caller can
|
||||
* still consume the original body as bytes when this is not the envelope; the
|
||||
* shape/absolute-http(s) checks remain as defense in depth.
|
||||
*/
|
||||
export const ARTIFACT_URL_ENVELOPE_MIME =
|
||||
'application/vnd.docsgpt.artifact-url+json';
|
||||
|
||||
export async function readPresignedUrlEnvelope(
|
||||
response: Response,
|
||||
): Promise<string | null> {
|
||||
const contentType = response.headers.get('Content-Type') ?? '';
|
||||
if (!contentType.toLowerCase().includes('application/json')) return null;
|
||||
if (!contentType.toLowerCase().includes(ARTIFACT_URL_ENVELOPE_MIME))
|
||||
return null;
|
||||
const data = await response
|
||||
.clone()
|
||||
.json()
|
||||
|
||||
@@ -175,21 +175,22 @@ class TestValidation:
|
||||
]
|
||||
|
||||
def test_shipped_defaults_validate(self):
|
||||
# The real shipped DEFAULT_CHAT_TOOLS must pass startup validation.
|
||||
# code_executor is NOT shipped default-on (it needs a running sandbox runner);
|
||||
# artifact_generator stays default-on and usable.
|
||||
# The real shipped DEFAULT_CHAT_TOOLS must pass startup validation. Neither
|
||||
# sandbox-backed tool is shipped default-on (both need a running runner).
|
||||
usable = default_tools.validate_default_chat_tools()
|
||||
assert "code_executor" not in default_tools.settings.DEFAULT_CHAT_TOOLS
|
||||
assert "artifact_generator" in usable
|
||||
assert "artifact_generator" not in default_tools.settings.DEFAULT_CHAT_TOOLS
|
||||
assert usable # the remaining defaults (memory/read_webpage/scheduler) validate
|
||||
|
||||
def test_code_executor_not_a_shipped_default_but_artifact_generator_is(self):
|
||||
# code_executor is enabled per-agent (needs a sandbox runner); it must not be
|
||||
# advertised on a fresh deploy. artifact_generator stays a shipped default.
|
||||
def test_sandbox_tools_not_shipped_defaults(self):
|
||||
# code_executor and artifact_generator both render/execute through the
|
||||
# sandbox runner, an opt-in service; a fresh deploy without a runner must
|
||||
# not advertise tools that hard-fail on every call. Enable per-agent.
|
||||
assert "code_executor" not in default_tools.settings.DEFAULT_CHAT_TOOLS
|
||||
assert "artifact_generator" in default_tools.settings.DEFAULT_CHAT_TOOLS
|
||||
assert "artifact_generator" not in default_tools.settings.DEFAULT_CHAT_TOOLS
|
||||
names = {r["name"] for r in default_tools.synthesized_default_tools(None)}
|
||||
assert "code_executor" not in names
|
||||
assert "artifact_generator" in names
|
||||
assert "artifact_generator" not in names
|
||||
|
||||
def test_tool_with_required_config_is_rejected(self, monkeypatch):
|
||||
# ``brave`` needs an API key.
|
||||
@@ -339,18 +340,20 @@ class TestResolveToolById:
|
||||
assert row["builtin"] is True
|
||||
assert row["default"] is True
|
||||
|
||||
@pytest.mark.parametrize("name", ["artifact_generator"])
|
||||
def test_sandbox_default_id_resolves_in_memory(self, name):
|
||||
# artifact_generator stays a default chat tool: its synthetic default id ->
|
||||
# name -> in-memory row (loaded user-scoped at execute time, like scheduler).
|
||||
# code_executor is no longer default-on, so its synthetic id no longer
|
||||
# resolves via this path (see open_notes on the per-agent reachability gap).
|
||||
@pytest.mark.parametrize("name", ["code_executor", "artifact_generator"])
|
||||
def test_sandbox_tool_id_resolves_in_memory(self, name):
|
||||
# Both sandbox tools are opt-in per agent (removed from DEFAULT_CHAT_TOOLS)
|
||||
# but stay registered as agent-selectable builtins, so their synthetic id
|
||||
# still resolves to an in-memory row (loaded user-scoped at execute time)
|
||||
# — an agent that enabled one never silently loses it.
|
||||
assert name not in default_tools.settings.DEFAULT_CHAT_TOOLS
|
||||
assert name in default_tools.BUILTIN_AGENT_TOOLS
|
||||
tool_id = default_tools.default_tool_id(name)
|
||||
assert default_tools.default_tool_name_for_id(tool_id) == name
|
||||
row = default_tools.resolve_tool_by_id(tool_id, "user-x")
|
||||
assert row is not None
|
||||
assert row["name"] == name
|
||||
assert row["id"] == tool_id
|
||||
assert row["builtin"] is True
|
||||
|
||||
def test_read_document_builtin_id_resolves_workflow_only(self):
|
||||
# read_document is a workflow-only builtin: its synthetic id resolves
|
||||
|
||||
@@ -609,6 +609,9 @@ class TestDownloadArtifact:
|
||||
assert resp.status_code == 200
|
||||
assert resp.json["success"] is True
|
||||
assert resp.json["url"].startswith("https://signed.example/")
|
||||
# The envelope carries a distinctive media type so the client keys off
|
||||
# a server signal, not the JSON body shape (open-redirect gadget guard).
|
||||
assert resp.mimetype == "application/vnd.docsgpt.artifact-url+json"
|
||||
|
||||
def test_stranger_denied(self, _patch_db, flask_app, monkeypatch):
|
||||
from application.api.user.artifacts.routes import DownloadArtifact
|
||||
|
||||
@@ -276,7 +276,7 @@ class TestSetupPeriodicTasks:
|
||||
|
||||
setup_periodic_tasks(sender)
|
||||
|
||||
assert sender.add_periodic_task.call_count == 12
|
||||
assert sender.add_periodic_task.call_count == 13
|
||||
|
||||
calls = sender.add_periodic_task.call_args_list
|
||||
|
||||
@@ -311,6 +311,9 @@ class TestSetupPeriodicTasks:
|
||||
# sandbox session reaper (60s)
|
||||
assert calls[11][0][0] == timedelta(seconds=60)
|
||||
assert calls[11][1].get("name") == "reap-sandbox-sessions"
|
||||
# stale workflow-run reaper (5m)
|
||||
assert calls[12][0][0] == timedelta(seconds=300)
|
||||
assert calls[12][1].get("name") == "reap-stale-workflow-runs"
|
||||
|
||||
|
||||
class TestMcpOauthTask:
|
||||
|
||||
@@ -28,6 +28,44 @@ def test_unknown_extension_is_rejected():
|
||||
assert "error" in out and "unsupported file type" in out["error"]
|
||||
|
||||
|
||||
def _make_zip(entries: Dict[str, bytes]) -> bytes:
|
||||
import io
|
||||
import zipfile
|
||||
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||
for name, data in entries.items():
|
||||
zf.writestr(name, data)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_zip_bomb_declared_size_over_cap_is_rejected(monkeypatch):
|
||||
# A tiny highly-compressible archive whose decompressed size exceeds the cap
|
||||
# must be rejected before any parser reads it (guards a zip-bomb OOM).
|
||||
monkeypatch.setattr(dr.settings, "DOCUMENT_MAX_DECOMPRESSED_BYTES", 1000, raising=False)
|
||||
data = _make_zip({"word/document.xml": b"A" * 50_000})
|
||||
assert len(data) < 1000 # the on-disk archive is well under the byte cap
|
||||
out = parse_document_bytes(data, "bomb.docx")
|
||||
assert "error" in out and "too much data" in out["error"]
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_zip_too_many_entries_is_rejected(monkeypatch):
|
||||
monkeypatch.setattr(dr.settings, "DOCUMENT_MAX_ARCHIVE_ENTRIES", 3, raising=False)
|
||||
data = _make_zip({f"f{i}.xml": b"x" for i in range(10)})
|
||||
out = parse_document_bytes(data, "many.xlsx")
|
||||
assert "error" in out and "too many entries" in out["error"]
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_reject_zip_bomb_ignores_non_zip_formats():
|
||||
# A non-zip extension (or a non-zip payload named .docx) is not gated here;
|
||||
# the format parser surfaces its own error downstream.
|
||||
assert dr._reject_zip_bomb(b"plain text", ".txt") is None
|
||||
assert dr._reject_zip_bomb(b"not a zip", ".docx") is None
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_size_cap_rejects_oversize(monkeypatch):
|
||||
monkeypatch.setattr(dr.settings, "DOCUMENT_PARSE_MAX_BYTES", 8, raising=False)
|
||||
|
||||
@@ -56,6 +56,23 @@ class TestCreateArtifact:
|
||||
assert v1["sha256"] == "abc"
|
||||
assert v1["spec"] == {"slides": []}
|
||||
|
||||
def test_persists_message_id_for_share_link_scope(self, pg_conn):
|
||||
# Share-token artifact access gates on the artifact's message being within
|
||||
# the conversation's shared first_n_queries snapshot, so creation paths
|
||||
# must tag the artifact with the producing message. Without a stored
|
||||
# message_id the share-link inheritance is dead (always denied).
|
||||
repo = _repo(pg_conn)
|
||||
msg_id = str(uuid.uuid4())
|
||||
artifact = repo.create_artifact(
|
||||
"user-1",
|
||||
"document",
|
||||
conversation_id=_conversation_id(),
|
||||
message_id=msg_id,
|
||||
filename="d.md",
|
||||
)
|
||||
assert str(artifact["message_id"]) == msg_id
|
||||
assert str(repo.get_artifact(artifact["id"])["message_id"]) == msg_id
|
||||
|
||||
def test_spec_only_version_allows_null_storage_path(self, pg_conn):
|
||||
repo = _repo(pg_conn)
|
||||
artifact = repo.create_artifact(
|
||||
@@ -308,6 +325,37 @@ class TestQuotaAccounting:
|
||||
repo.create_artifact("alice", "document", conversation_id=_conversation_id())
|
||||
assert repo.total_bytes_for_user("alice") == 0
|
||||
|
||||
def test_total_bytes_dedupes_restored_version_by_storage_path(self, pg_conn):
|
||||
# Restore appends a version that re-points at an existing version's stored
|
||||
# object (same storage_path + size). It stores no new bytes, so it must not
|
||||
# inflate the user's usage.
|
||||
repo = _repo(pg_conn)
|
||||
art = repo.create_artifact(
|
||||
"alice",
|
||||
"document",
|
||||
conversation_id=_conversation_id(),
|
||||
storage_path="inputs/alice/artifacts/x/v1/deck.pptx",
|
||||
size=500,
|
||||
filename="deck.pptx",
|
||||
)
|
||||
# A genuine new version stores new bytes at a new key (+300 -> 800).
|
||||
repo.append_version(
|
||||
art["id"],
|
||||
storage_path="inputs/alice/artifacts/x/v2/deck.pptx",
|
||||
size=300,
|
||||
filename="deck.pptx",
|
||||
)
|
||||
assert repo.total_bytes_for_user("alice") == 800
|
||||
# Restoring v1 re-points at v1's object; usage stays 800, not 1300.
|
||||
source = repo.get_version(art["id"], 1)
|
||||
repo.append_version(
|
||||
art["id"],
|
||||
storage_path=source["storage_path"],
|
||||
size=source["size"],
|
||||
filename=source["filename"],
|
||||
)
|
||||
assert repo.total_bytes_for_user("alice") == 800
|
||||
|
||||
|
||||
class TestQuotaEnforcement:
|
||||
"""Exercises the shared enforcement helper against a real repo + connection."""
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
|
||||
from application.storage.db.repositories.workflows import WorkflowsRepository
|
||||
@@ -98,3 +98,36 @@ class TestListForWorkflow:
|
||||
wf = _wf(pg_conn)
|
||||
repo = _repo(pg_conn)
|
||||
assert repo.list_for_workflow(wf["id"]) == []
|
||||
|
||||
|
||||
class TestMarkStaleRunningFailed:
|
||||
def test_fails_old_running_run(self, pg_conn):
|
||||
wf = _wf(pg_conn)
|
||||
repo = _repo(pg_conn)
|
||||
old = datetime.now(timezone.utc) - timedelta(hours=2)
|
||||
run = repo.create(wf["id"], "user-1", "running", started_at=old)
|
||||
|
||||
reaped = repo.mark_stale_running_failed(
|
||||
datetime.now(timezone.utc) - timedelta(hours=1)
|
||||
)
|
||||
assert reaped == 1
|
||||
fetched = repo.get(run["id"])
|
||||
assert fetched["status"] == "failed"
|
||||
assert fetched["ended_at"] is not None
|
||||
assert "did not complete" in (fetched["result"] or {}).get("error", "")
|
||||
|
||||
def test_leaves_recent_running_and_terminal_runs(self, pg_conn):
|
||||
wf = _wf(pg_conn)
|
||||
repo = _repo(pg_conn)
|
||||
now = datetime.now(timezone.utc)
|
||||
recent = repo.create(wf["id"], "user-1", "running", started_at=now)
|
||||
# A terminal run that started long ago must not be touched (has ended_at).
|
||||
done = repo.create(
|
||||
wf["id"], "user-1", "completed",
|
||||
started_at=now - timedelta(hours=2), ended_at=now - timedelta(hours=2),
|
||||
)
|
||||
|
||||
reaped = repo.mark_stale_running_failed(now - timedelta(hours=1))
|
||||
assert reaped == 0
|
||||
assert repo.get(recent["id"])["status"] == "running"
|
||||
assert repo.get(done["id"])["status"] == "completed"
|
||||
Reference in new issue
Block a user