Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion docker/personal-agent/personal-agent-entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,6 @@ su - pa-desktop -c 'export DISPLAY=:99 XAUTHORITY=/run/pa-desktop/.Xauthority; s
# Live screen: VNC listens only on a socket in the private directory; pa-stream checks the token.
su - pa-desktop -c 'sleep 2; while true; do x11vnc -display :99 -auth /run/pa-desktop/.Xauthority -rfbport 0 -unixsock /run/pa-desktop/vnc.sock -forever -shared -nopw -quiet -nowf -noscr -ncache 0 -cursor most -xkb -wait 10 -defer 10; sleep 1; done' >/run/pa-desktop/vnc.log 2>&1 &
su - pa-desktop -c 'while true; do /opt/python/current/bin/python3 -I /usr/local/bin/pa-stream; sleep 1; done' >/run/pa-desktop/stream.log 2>&1 &
exec sleep infinity
# PID 1 reaps every orphan and kills what task commands (UID 1000) leave behind;
# the desktop above runs as UID 1001 and is only reaped.
exec sandbox-init 1000
9 changes: 9 additions & 0 deletions docker/sandbox/Dockerfile.opensandbox
Original file line number Diff line number Diff line change
Expand Up @@ -223,3 +223,12 @@ RUN mkdir -p /home/ubuntu/.local /home/ubuntu/.jupyter /home/ubuntu/.npm \
# Real launch detects driver/browser revision mismatches before delivery.
COPY docker/sandbox/verify-browser-runtime.py /opt/verify-browser-runtime.py
RUN runuser -u ubuntu -- $PY_BIN /opt/verify-browser-runtime.py --container-isolated

# ─── sandbox-init(PID 1 + 命令的 subreaper)─────────────────────────────
# 由 provider 的 entrypoint 作为 PID 1 启动:回收所有孤儿进程,并杀掉沙盒用户
# (SANDBOX_RUN_UID)在命令结束后留下的进程;每条用户命令经 `sandbox-init
# --subreaper` 运行,setsid / 两次 fork 也逃不出命令的进程树。放在末尾,
# 改动不击穿上面的大层缓存。
COPY docker/sandbox/sandbox-init.c /tmp/sandbox-init.c
RUN gcc -O2 -Wall -Werror -o /usr/local/bin/sandbox-init /tmp/sandbox-init.c \
&& rm /tmp/sandbox-init.c
78 changes: 78 additions & 0 deletions docker/sandbox/sandbox-init.c
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
/*
* PID 1 of an OpenSandbox container, and the subreaper every user command runs under.
*
* sandbox-init <uid> as PID 1: reap every child, and SIGKILL the ones
* running as <uid>
* sandbox-init --subreaper cmd ... become a child subreaper, then exec cmd
*
* User commands run as <uid> under --subreaper, so whatever they start stays in their
* subtree for as long as they run, however it detaches (setsid, double fork). A leftover
* only reaches PID 1 once its command has ended, and is killed there: no process of
* <uid> outlives the command that started it. Sandbox services run as root and are
* only reaped.
*/
#define _GNU_SOURCE
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/prctl.h>
#include <sys/wait.h>
#include <time.h>
#include <unistd.h>

/* The real uid: a process cannot change it, unlike the owner of /proc/<pid>. */
static long real_uid(long pid)
{
char path[64], line[256];
long uid = -1;
snprintf(path, sizeof path, "/proc/%ld/status", pid);
FILE *status = fopen(path, "r");
if (!status)
return -1;
while (fgets(line, sizeof line, status))
if (sscanf(line, "Uid: %ld", &uid) == 1)
break;
fclose(status);
return uid;
}

static void kill_orphans(long uid)
{
/* Nothing is reaped during the scan, so no PID read here can be reused before its kill. */
FILE *children = fopen("/proc/1/task/1/children", "r");
long pid;
if (!children)
return;
while (fscanf(children, "%ld", &pid) == 1)
if (real_uid(pid) == uid)
kill((pid_t)pid, SIGKILL);
fclose(children);
}

int main(int argc, char **argv)
{
if (argc > 2 && strcmp(argv[1], "--subreaper") == 0) {
if (prctl(PR_SET_CHILD_SUBREAPER, 1) == 0)
execvp(argv[2], argv + 2);
perror("sandbox-init");
return 127;
}
if (argc != 2) {
fprintf(stderr, "usage: sandbox-init <uid> | sandbox-init --subreaper cmd ...\n");
return 2;
}
long uid = strtol(argv[1], NULL, 10);
sigset_t chld;
sigemptyset(&chld);
sigaddset(&chld, SIGCHLD);
sigprocmask(SIG_BLOCK, &chld, NULL);
/* An orphan is adopted without any signal, so look for leftovers every second too. */
const struct timespec tick = {1, 0};
for (;;) {
sigtimedwait(&chld, NULL, &tick);
while (waitpid(-1, NULL, WNOHANG) > 0)
;
kill_orphans(uid);
}
}
15 changes: 11 additions & 4 deletions src/backend/api/routes/file_archive.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,14 @@
import tempfile
from urllib.parse import quote

from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, Request
from fastapi.responses import Response
from sqlalchemy.orm import Session

from core.auth.backend import UserContext, get_current_user
from core.files.content.archive_browser import ArchiveError, MAX_ARCHIVE_BYTES, inspect_archive, read_archive_member
from core.db.engine import get_db
from core.infra.byte_range import byte_range_response, is_continuation_range
from core.infra.exceptions import StorageError
from core.infra.responses import success_response
from core.files.storage import get_storage
Expand All @@ -22,7 +23,7 @@


@contextmanager
def _source(file_id, user, db):
def _source(file_id, user, db, *, audit: bool = True):
from api.routes.files import _authorize_access, _load_artifact_item, _record_audit

item = _load_artifact_item(file_id, db)
Expand Down Expand Up @@ -54,6 +55,8 @@ def _source(file_id, user, db):
raise HTTPException(exc.status, detail=exc.code) from exc
except (OSError, KeyError, StorageError) as exc:
raise HTTPException(503, detail="archive_read_failed") from exc
if not audit:
return
_record_audit(
user=user,
db=db,
Expand All @@ -78,13 +81,16 @@ def list_archive(

@router.get("/{file_id}/archive/member", summary="读取压缩包内文件")
def archive_member(
request: Request,
file_id: str,
path: str = Query(min_length=1, max_length=1024),
inline: bool = False,
user: UserContext = Depends(get_current_user),
db: Session = Depends(get_db),
):
with _source(file_id, user, db) as (source, name, _):
range_header = request.headers.get("range")
# A player's mid-file seeks re-read the member; audit only the first read.
with _source(file_id, user, db, audit=not is_continuation_range(range_header)) as (source, name, _):
data = read_archive_member(source, name, path)
filename = PurePosixPath(path).name
mime = mimetypes.guess_type(filename)[0] or "application/octet-stream"
Expand All @@ -99,8 +105,9 @@ def archive_member(
if mime in ("text/html", "image/svg+xml"):
mime = "text/plain"
safe_inline = inline
return Response(
return byte_range_response(
data,
range_header,
media_type=mime,
headers={
"Cache-Control": "no-store",
Expand Down
42 changes: 19 additions & 23 deletions src/backend/api/routes/files.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,18 @@
router = APIRouter(prefix="/files", tags=["files"])
router.include_router(archive_router)
router.include_router(local_actions_router)
POWERPOINT_EXTENSIONS = {".ppt", ".pptx"}
POWERPOINT_MIME_MARKERS = ("presentationml", "powerpoint")
WORD_EXTENSIONS = {".doc", ".docx"}
WORD_MIME_MARKERS = ("wordprocessingml", "msword")
# Documents rendered to PDF by LibreOffice for browser preview: legacy binary
# Word/PowerPoint, OOXML, OpenDocument text/presentation and RTF.
OFFICE_PREVIEW_EXTENSIONS = {".doc", ".docx", ".rtf", ".odt", ".ppt", ".pptx", ".odp"}
OFFICE_PREVIEW_MIME_MARKERS = (
"wordprocessingml",
"msword",
"presentationml",
"powerpoint",
"opendocument.text",
"opendocument.presentation",
"application/rtf",
)


def _load_artifact_item(file_id: str, db: Session) -> dict[str, Any]:
Expand Down Expand Up @@ -208,28 +216,16 @@ def _prepare_local_file(
return str(temp_path)


def _is_powerpoint_file(item: dict[str, Any]) -> bool:
name = str(item.get("name", ""))
def _is_office_previewable(item: dict[str, Any]) -> bool:
ext = Path(str(item.get("name", ""))).suffix.lower()
mime_type = str(item.get("mime_type", "")).lower()
ext = Path(name).suffix.lower()
return ext in POWERPOINT_EXTENSIONS or any(
marker in mime_type for marker in POWERPOINT_MIME_MARKERS
return ext in OFFICE_PREVIEW_EXTENSIONS or any(
marker in mime_type for marker in OFFICE_PREVIEW_MIME_MARKERS
)


def _is_word_file(item: dict[str, Any]) -> bool:
name = str(item.get("name", ""))
mime_type = str(item.get("mime_type", "")).lower()
ext = Path(name).suffix.lower()
return ext in WORD_EXTENSIONS or any(marker in mime_type for marker in WORD_MIME_MARKERS)


def _is_office_previewable(item: dict[str, Any]) -> bool:
return _is_powerpoint_file(item) or _is_word_file(item)


def _convert_office_to_pdf(source_path: str, file_id: str) -> tuple[str, str]:
"""Render an Office document (PPT/PPTX/DOC/DOCX) to PDF via headless LibreOffice."""
"""Render an Office document (DOC/DOCX/RTF/ODT/PPT/PPTX/ODP) to PDF via headless LibreOffice."""
libreoffice = find_libreoffice_binary()
if not libreoffice:
raise RuntimeError(
Expand Down Expand Up @@ -421,7 +417,7 @@ def preview_file(
user: Optional[UserContext] = Depends(require_auth(required=False)),
db: Session = Depends(get_db),
):
"""将 Office 文件(PPT/PPTX/DOC/DOCX)经 LibreOffice 转为可在浏览器内联预览的 PDF。
"""将 Office 文件(DOC/DOCX/RTF/ODT/PPT/PPTX/ODP)经 LibreOffice 转为可在浏览器内联预览的 PDF。

需通过文件访问鉴权;目前 format 仅支持 pdf。
"""
Expand All @@ -439,7 +435,7 @@ def preview_file(
if not _is_office_previewable(item):
raise HTTPException(
status_code=400,
detail="Only Word (DOC/DOCX) and PowerPoint (PPT/PPTX) files support preview",
detail="Only Word (DOC/DOCX/RTF/ODT) and PowerPoint (PPT/PPTX/ODP) files support preview",
)

source_path = _prepare_local_file(
Expand Down
8 changes: 6 additions & 2 deletions src/backend/api/routes/v1/space_files.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from core.files.spaces.mounts import mount_revision
from core.files.spaces.files import MAX_TRANSFER_BYTES, SpaceFiles
from core.files.storage import get_storage
from core.infra.byte_range import byte_range_response

router = APIRouter(prefix="/v1/myspace", tags=["Space Files"])

Expand Down Expand Up @@ -43,11 +44,14 @@ def entries(space: str, path: str = "", cursor: str | None = None,
return success_response(data=files.list(space, path, cursor=cursor, limit=limit, keyword=keyword))

@router.get("/content")
def download(space: str, path: str, inline: bool = False, files: SpaceFiles = Depends(service)):
def download(request: Request, space: str, path: str, inline: bool = False,
files: SpaceFiles = Depends(service)):
data, info = files.read(space, path)
mime = mimetypes.guess_type(info.name)[0] or "application/octet-stream"
safe_inline = inline and (mime.startswith(("image/", "audio/", "video/", "text/")) or mime == "application/pdf")
return Response(content=data, media_type=mime if safe_inline else "application/octet-stream",
# Range lets the canvas audio/video player seek without refetching the whole file.
return byte_range_response(data, request.headers.get("range"),
media_type=mime if safe_inline else "application/octet-stream",
headers={"Content-Disposition": ("inline" if safe_inline else "attachment") + "; filename*=UTF-8''" + quote(info.name, safe=""),
"Content-Security-Policy": "sandbox",
"Cache-Control": "no-store", "X-Content-Type-Options": "nosniff"})
Expand Down
7 changes: 4 additions & 3 deletions src/backend/api/routes/v1/space_previews.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,9 @@
import tempfile
from urllib.parse import quote

from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Response
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request, Response
from core.files.content.archive_browser import ArchiveError, inspect_archive, read_archive_member
from core.infra.byte_range import byte_range_response
from core.infra.responses import success_response
from core.files.spaces.files import SpaceFiles
from api.routes.v1.space_files import service
Expand Down Expand Up @@ -58,7 +59,7 @@ def archive(space: str, path: str, response: Response, files: SpaceFiles = Depen


@router.get("/archive/member")
def member(space: str, path: str, member: str, inline: bool = False,
def member(request: Request, space: str, path: str, member: str, inline: bool = False,
files: SpaceFiles = Depends(service)):
with source(files, space, path) as (local, name):
data = read_archive_member(local, name, member)
Expand All @@ -67,7 +68,7 @@ def member(space: str, path: str, member: str, inline: bool = False,
if mime in ("text/html", "image/svg+xml"):
mime = "text/plain"
safe = inline and (mime.startswith(("image/", "audio/", "video/", "text/")) or mime == "application/pdf")
return Response(data, media_type=mime, headers={
return byte_range_response(data, request.headers.get("range"), media_type=mime, headers={
"Cache-Control": "no-store", "X-Content-Type-Options": "nosniff",
"Content-Security-Policy": "sandbox; default-src 'none'",
"Content-Disposition": ("inline" if safe else "attachment") + "; filename*=UTF-8''" + quote(filename, safe=""),
Expand Down
69 changes: 46 additions & 23 deletions src/backend/core/chat/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,12 @@
from datetime import datetime
from typing import Any, Dict, List, Optional

from pydantic import ValidationError
from sqlalchemy.orm import Session

from core.capabilities.catalog.resolver import resolve_all_runtime_enabled
from core.auth.users import UserService
from core.chat.schemas import QuoteFileSource, QuotedFollowUpItem

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -73,43 +75,64 @@ def generate_smart_title(message: str) -> str:
return message if len(message) <= 20 else message[:20] + "..."


def _parse_quote(quoted_follow_up: Optional[Any]) -> Optional[QuotedFollowUpItem]:
"""Request model as-is; a stored ``extra_data`` dict is validated the same way, and an
unreadable one is dropped rather than failing the replayed turn."""
if quoted_follow_up is None or isinstance(quoted_follow_up, QuotedFollowUpItem):
return quoted_follow_up
try:
return QuotedFollowUpItem.model_validate(quoted_follow_up)
except ValidationError:
return None


def _render_quote(quote: QuotedFollowUpItem, message: str) -> str:
source = quote.source
if isinstance(source, QuoteFileSource):
origin = f"文件《{source.file_name}》(文件 ID:{source.file_id}"
origin += f",位置:{source.locator})" if source.locator else ")"
return (
f"用户在右侧预览中打开了{origin},选取了其中一段原文并就它提问。\n"
"要求:\n"
"1. 将【引用原文】视为这次提问直接针对的内容;“这段/这里/它/上面”等指代优先指向它。\n"
"2. 回答时以引用原文为准;需要文件其余部分的上下文时,按上面的文件名或文件 ID 查阅原文件。\n"
"3. 直接回答用户的问题,不要重复无关背景,也不要提及本提示词。\n\n"
f"【引用原文】(摘自{origin})\n{quote.text}\n\n"
f"【用户提问】\n{message}"
)
return (
"你正在回答同一会话中的一条追问消息。请优先结合当前会话上下文,并重点参考下面的引用原文来理解代词、省略和上下文指向。\n"
"要求:\n"
"1. 将【引用原文】视为这次追问直接关联的内容。\n"
"2. 若用户问题中出现“这个/这个点/它/上述/刚才提到的”等指代,优先从【引用原文】和最近几轮会话补全语义。\n"
"3. 直接回答用户当前追问,不要重复无关背景,也不要提及本提示词或“根据引用原文”。\n\n"
f"【引用原文】\n{quote.text}\n\n"
f"【用户追问】\n{message}"
)


def build_effective_user_message(
message: str,
quoted_follow_up: Optional[Any],
reference_block: str = "",
) -> str:
"""Splice the "follow-up quote" into the user message (domain-level assembly shared by send and history replay, not HTTP-layer logic).
"""Splice the user's quote into the user message (domain-level assembly shared by send and history replay, not HTTP-layer logic).

``quoted_follow_up`` accepts any object with a ``text`` attribute (pydantic model) or
a ``{"text": ...}`` dict; returns unchanged when there is no quote.
``quoted_follow_up`` is a ``QuotedFollowUpItem`` or its stored dict form; its
``source`` decides the framing — a passage from an earlier message in this chat,
or a passage from a file the user has open in the canvas (named, with its ID and
locator, so the model knows which file and where). No quote → message unchanged.

``reference_block`` 是用户这一轮显式引用的历史会话名片(见
``core.chat.references.render_reference_block``),拼在最前面:它交代
的是"回答这条消息要参考哪些会话",属于这一轮的背景,先于追问引用和用户原话。
"""
if isinstance(quoted_follow_up, dict):
quote_text = quoted_follow_up.get("text")
else:
quote_text = getattr(quoted_follow_up, "text", None) if quoted_follow_up is not None else None
prefix = (reference_block or "").strip()
prefix = f"{prefix}\n\n" if prefix else ""

if not quote_text:
quote = _parse_quote(quoted_follow_up)
if quote is None:
return f"{prefix}{message}"

quote = str(quote_text).strip()
if not quote:
return f"{prefix}{message}"

return prefix + (
"你正在回答同一会话中的一条追问消息。请优先结合当前会话上下文,并重点参考下面的引用原文来理解代词、省略和上下文指向。\n"
"要求:\n"
"1. 将【引用原文】视为这次追问直接关联的内容。\n"
"2. 若用户问题中出现“这个/这个点/它/上述/刚才提到的”等指代,优先从【引用原文】和最近几轮会话补全语义。\n"
"3. 直接回答用户当前追问,不要重复无关背景,也不要提及本提示词或“根据引用原文”。\n\n"
f"【引用原文】\n{quote}\n\n"
f"【用户追问】\n{message}"
)
return prefix + _render_quote(quote, message)


def resolve_user_facing_error(exc: Exception) -> str:
Expand Down
Loading
Loading