mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-06-16 01:35:36 -04:00
620fdd0859
* feat(agent): workspace confinement via context-local binding + get_workspace tool Bind the per-turn workspace once in execute_tool_block; the shared path resolvers (_resolve_tool_path / _resolve_search_root) and the subprocess cwd helper (agent_cwd) read it, so file tools + bash/python are confined centrally and a new tool that uses the shared helpers cannot accidentally bypass it. Adds the admin-gated /api/workspace/browse picker, a workspace pill + directory modal (reusing existing modal/button CSS), the /workspace slash command, and a get_workspace tool (replaces a system-prompt block). Confinement is OS-agnostic (realpath/normcase/commonpath) and docker-safe (container paths, no host assumptions). Reopens #2023. * ux(workspace): clarify workspace is not a sandbox Picker modal note + pill tooltip + get_workspace tool/output wording now state plainly: read_file/write_file/edit_file/grep/glob/ls are confined to the folder, but bash/python only start there (cwd) and are not sandboxed. Modal note reuses the existing .muted class. * fix(agent): treat an active workspace as file-work intent A vague low-signal message (e.g. "look at the local project") matches no domain keywords, so tool retrieval is skipped and only always-available tools are offered — leaving the agent with no file access even though a workspace is set. When a workspace is active, include the file/code tools (incl. get_workspace) on low-signal turns so the agent can act on the folder. Also requires the tool index (ChromaDB) to be reachable for normal retrieval; that is an environment dependency, not part of this change. * ux(workspace): hide pill + overflow entry in chat mode Workspace only scopes the agent's file/shell tools, so the pill and the overflow 'Workspace' entry are agent-only now — hidden in chat mode like the bash toggle. Mode read from the DOM in syncWorkspaceIndicator; applyMode() is called from the agent/chat setMode handler. * prompt(tools): steer bash/python to defer to the dedicated file tools bash/python schema descriptions (what native-tool-calling models read) were bare and gave no steer, so models would do file ops via the shell (e.g. writing SVG/HTML, which then dumps raw markup into the tool preview). Tell bash/python in the schema + tool-index + prompt section to prefer read_file/write_file/ edit_file/grep/glob/ls and only be used for what those do not cover. * prompt(tools): keep bash/python deferral generic (no hardcoded tool names) Reference 'a dedicated tool' rather than listing read_file/write_file/grep/etc. by name, so the guidance does not go stale if those tools are renamed. * style(workspace): drop em-dashes from added code comments/strings * ux(workspace): terser non-sandbox note in picker (no tool-name list) * ux(workspace): mirror terse non-sandbox wording in pill tooltip * chore: untrack local venv symlink (run-only, not part of the feature) * prompt(workspace): keep get_workspace text generic (no hardcoded tool names) * fix(agent): low-signal + workspace surfaces only read-only file tools Intersect the files tool group with PLAN_MODE_READONLY_TOOLS so a vague message in a workspace exposes read_file/grep/glob/ls/get_workspace for exploration, but not write_file/edit_file/bash/python -- those wait for a request that actually calls for them (RAG retrieval still adds them on a real ask). * feat(workspace): cap browse listing at 500 dirs with a truncated hint Mirror the filesystem_tools._CODENAV_MAX_HITS pattern with a module-local _MAX_BROWSE_DIRS so a directory with thousands of children does not dump every row into the picker; the response carries a truncated flag and the modal tells the user to type a path to jump in. * chore: untrack local venv symlink (run-only artifact) * fix(workspace): vet the workspace root against the sensitive-path deny list at bind time The in-workspace resolver deny-lists sensitive paths inside the workspace, but the empty-path search root is the workspace itself, so a workspace of ~/.ssh could be listed via ls with no path. vet_workspace() (public, in tool_execution next to the resolvers) rejects non-directories and sensitive roots before the path is ever bound; chat_routes uses it instead of its inline isdir check. * fix(workspace): reject filesystem roots and stop showing rejected workspaces as active Review findings from #3665: P2: vet_workspace accepted / (and would accept drive/UNC roots), which makes every absolute path 'inside' the workspace and collapses confinement into host-wide file access. A root is its own dirname, so reject when dirname(resolved) == resolved; the browse response now carries a selectable flag and the picker disables 'Use this folder' on unselectable dirs. P3: /workspace set stored any string client-side and the chat route silently dropped rejected values, so the pill could claim a confinement that was not in effect. New admin-gated /api/workspace/vet validates manual paths before they persist (canonical path returned), and when a posted workspace is rejected at send time the stream emits workspace_rejected so the client clears the stored value and toasts instead of continuing silently. * fix(workspace): check caller privilege before vetting the posted workspace Review finding: /api/chat_stream called vet_workspace() on the posted value for every caller and emitted workspace_rejected on failure, so a non-admin who can chat but cannot use file/shell tools could distinguish existing directories from missing/file/sensitive/root paths by whether the event appeared. The resolution now lives in _resolve_request_workspace, which drops the submitted value uniformly for non-admin callers, with no vetting and no event, before the path ever touches the filesystem. Admin and single-user behavior is unchanged. Test pins that valid and invalid paths are indistinguishable for a non-admin and that vet_workspace is never invoked for them.
154 lines
5.3 KiB
Python
154 lines
5.3 KiB
Python
import asyncio
|
|
import sys
|
|
import time
|
|
import collections
|
|
from typing import Optional, Callable, Awaitable, Tuple, Dict
|
|
from src.constants import MAX_OUTPUT_CHARS
|
|
|
|
DEFAULT_BASH_TIMEOUT = 60 * 60 # 1 hour
|
|
DEFAULT_PYTHON_TIMEOUT = 60 * 60
|
|
|
|
PROGRESS_INTERVAL_S = 2.0
|
|
PROGRESS_TAIL_LINES = 12
|
|
|
|
async def _run_subprocess_streaming(
|
|
proc: asyncio.subprocess.Process,
|
|
*,
|
|
timeout: float,
|
|
progress_cb: Optional[Callable[[Dict], Awaitable[None]]] = None,
|
|
) -> Tuple[str, str, Optional[int], bool]:
|
|
started = time.time()
|
|
stdout_full: list[str] = []
|
|
stderr_full: list[str] = []
|
|
tail = collections.deque(maxlen=PROGRESS_TAIL_LINES)
|
|
|
|
async def _reader(stream, full_buf, label: str):
|
|
if stream is None:
|
|
return
|
|
while True:
|
|
line = await stream.readline()
|
|
if not line:
|
|
break
|
|
decoded = line.decode("utf-8", errors="replace").rstrip("\n")
|
|
full_buf.append(decoded)
|
|
if label == "err":
|
|
tail.append(f"! {decoded}")
|
|
else:
|
|
tail.append(decoded)
|
|
|
|
async def _progress_emitter():
|
|
await asyncio.sleep(PROGRESS_INTERVAL_S)
|
|
while True:
|
|
if progress_cb:
|
|
try:
|
|
await progress_cb({
|
|
"elapsed_s": round(time.time() - started, 1),
|
|
"tail": "\n".join(list(tail)),
|
|
})
|
|
except Exception:
|
|
pass
|
|
await asyncio.sleep(PROGRESS_INTERVAL_S)
|
|
|
|
rd_out = asyncio.create_task(_reader(proc.stdout, stdout_full, "out"))
|
|
rd_err = asyncio.create_task(_reader(proc.stderr, stderr_full, "err"))
|
|
prog_task = asyncio.create_task(_progress_emitter()) if progress_cb else None
|
|
|
|
timed_out = False
|
|
try:
|
|
await asyncio.wait_for(proc.wait(), timeout=timeout)
|
|
except asyncio.TimeoutError:
|
|
timed_out = True
|
|
try:
|
|
proc.kill()
|
|
except Exception:
|
|
pass
|
|
try:
|
|
await asyncio.wait_for(proc.wait(), timeout=2)
|
|
except Exception:
|
|
pass
|
|
except asyncio.CancelledError:
|
|
try:
|
|
proc.kill()
|
|
except Exception:
|
|
pass
|
|
try:
|
|
await asyncio.wait_for(proc.wait(), timeout=2)
|
|
except Exception:
|
|
pass
|
|
for t in (rd_out, rd_err):
|
|
t.cancel()
|
|
if prog_task is not None:
|
|
prog_task.cancel()
|
|
raise
|
|
finally:
|
|
if prog_task is not None and not prog_task.done():
|
|
prog_task.cancel()
|
|
try:
|
|
await prog_task
|
|
except (asyncio.CancelledError, Exception):
|
|
pass
|
|
for t in (rd_out, rd_err):
|
|
try:
|
|
await asyncio.wait_for(t, timeout=1)
|
|
except Exception:
|
|
pass
|
|
|
|
return (
|
|
"\n".join(stdout_full),
|
|
"\n".join(stderr_full),
|
|
proc.returncode,
|
|
timed_out,
|
|
)
|
|
|
|
class BashTool:
|
|
async def execute(self, content: str, ctx: dict) -> dict:
|
|
from src.tool_execution import agent_cwd, _truncate
|
|
progress_cb = ctx.get("progress_cb")
|
|
_subproc_env = ctx.get("subproc_env")
|
|
proc = await asyncio.create_subprocess_shell(
|
|
content,
|
|
stdout=asyncio.subprocess.PIPE,
|
|
stderr=asyncio.subprocess.PIPE,
|
|
env=_subproc_env,
|
|
cwd=agent_cwd(),
|
|
)
|
|
stdout, stderr, rc, timed_out = await _run_subprocess_streaming(
|
|
proc,
|
|
timeout=DEFAULT_BASH_TIMEOUT,
|
|
progress_cb=progress_cb,
|
|
)
|
|
if timed_out:
|
|
return {"error": f"bash: timed out after {DEFAULT_BASH_TIMEOUT}s — process killed", "exit_code": 124, "stdout": _truncate(stdout, MAX_OUTPUT_CHARS), "stderr": _truncate(stderr, MAX_OUTPUT_CHARS)}
|
|
output = stdout.rstrip()
|
|
err = stderr.rstrip()
|
|
if err:
|
|
output = (output + "\nSTDERR: " + err).strip() if output else "STDERR: " + err
|
|
output = _truncate(output, MAX_OUTPUT_CHARS)
|
|
return {"output": output or "(no output)", "exit_code": rc or 0}
|
|
|
|
class PythonTool:
|
|
async def execute(self, content: str, ctx: dict) -> dict:
|
|
from src.tool_execution import agent_cwd, _truncate
|
|
progress_cb = ctx.get("progress_cb")
|
|
_subproc_env = ctx.get("subproc_env")
|
|
proc = await asyncio.create_subprocess_exec(
|
|
(sys.executable or "python"), "-I", "-c", content,
|
|
stdout=asyncio.subprocess.PIPE,
|
|
stderr=asyncio.subprocess.PIPE,
|
|
env=_subproc_env,
|
|
cwd=agent_cwd(),
|
|
)
|
|
stdout, stderr, rc, timed_out = await _run_subprocess_streaming(
|
|
proc,
|
|
timeout=DEFAULT_PYTHON_TIMEOUT,
|
|
progress_cb=progress_cb,
|
|
)
|
|
if timed_out:
|
|
return {"error": f"python: timed out after {DEFAULT_PYTHON_TIMEOUT}s — process killed", "exit_code": 124, "stdout": _truncate(stdout, MAX_OUTPUT_CHARS), "stderr": _truncate(stderr, MAX_OUTPUT_CHARS)}
|
|
output = stdout.rstrip()
|
|
err = stderr.rstrip()
|
|
if err:
|
|
output = (output + "\nSTDERR: " + err).strip() if output else "STDERR: " + err
|
|
output = _truncate(output, MAX_OUTPUT_CHARS)
|
|
return {"output": output or "(no output)", "exit_code": rc or 0}
|