@bitseek/hermes-webui 0.1.0-beta.0 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/vendor/agent-frontend-shell/.bitseek-source.json +2 -2
- package/vendor/agent-frontend-shell/CHANGELOG.md +178 -1
- package/vendor/agent-frontend-shell/CONTRIBUTORS.md +5 -5
- package/vendor/agent-frontend-shell/api/agent_health.py +134 -0
- package/vendor/agent-frontend-shell/api/config.py +145 -104
- package/vendor/agent-frontend-shell/api/gateway_chat.py +56 -12
- package/vendor/agent-frontend-shell/api/helpers.py +4 -2
- package/vendor/agent-frontend-shell/api/models.py +202 -20
- package/vendor/agent-frontend-shell/api/paths.py +77 -0
- package/vendor/agent-frontend-shell/api/plugins.py +185 -0
- package/vendor/agent-frontend-shell/api/profiles.py +95 -16
- package/vendor/agent-frontend-shell/api/routes.py +831 -30
- package/vendor/agent-frontend-shell/api/run_journal.py +1 -0
- package/vendor/agent-frontend-shell/api/state_sync.py +5 -4
- package/vendor/agent-frontend-shell/api/streaming.py +211 -56
- package/vendor/agent-frontend-shell/api/todo_state.py +122 -0
- package/vendor/agent-frontend-shell/api/updates.py +30 -3
- package/vendor/agent-frontend-shell/api/upload.py +251 -18
- package/vendor/agent-frontend-shell/api/workspace.py +323 -65
- package/vendor/agent-frontend-shell/bitseek_docs/BitSeek_Claw_Operation_Manual_EN.docx +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/BitSeek_Claw_Operation_Manual_ZH.docx +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/00-Installation.md +174 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/01-Overview.md +128 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/02-Page-Operations.md +461 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/README.md +61 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/images/ai-colleagues.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/images/chat-area.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/images/kanban.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/images/main-page.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/images/memory-notes.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/images/memory-overview.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/images/memory-profile.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/images/memory-soul.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/images/memory.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/images/navigation-bar.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/images/settings-appearance.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/images/settings-conversation.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/images/settings-overview.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/images/settings-plugins.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/images/settings-preferences.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/images/settings-providers.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/images/settings-system.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/images/settings.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/images/sidebar.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/images/skills.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/images/tasks.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/images/workspace-panel.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/md_to_docx.py +351 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/00-/345/256/211/350/243/205/345/220/257/345/212/250.md +174 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/01-/346/225/264/344/275/223/346/246/202/350/247/210.md +128 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/02-/351/241/265/351/235/242/346/223/215/344/275/234.md +463 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/README.md +61 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/images/ai-colleagues.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/images/chat-area.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/images/kanban.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/images/main-page.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/images/memory-notes.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/images/memory-overview.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/images/memory-profile.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/images/memory-soul.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/images/memory.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/images/navigation-bar.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/images/settings-appearance.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/images/settings-conversation.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/images/settings-overview.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/images/settings-plugins.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/images/settings-preferences.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/images/settings-providers.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/images/settings-system.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/images/settings.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/images/sidebar.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/images/skills.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/images/tasks.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/images/workspace-panel.png +0 -0
- package/vendor/agent-frontend-shell/build-release.sh +62 -0
- package/vendor/agent-frontend-shell/ctl.sh +1 -0
- package/vendor/agent-frontend-shell/docker-compose.local.yml +33 -0
- package/vendor/agent-frontend-shell/docker-compose.yml +8 -0
- package/vendor/agent-frontend-shell/docker_init.bash +1 -0
- package/vendor/agent-frontend-shell/docs/rfcs/hermes-run-adapter-contract.md +74 -15
- package/vendor/agent-frontend-shell/extensions/common/index.css +6 -0
- package/vendor/agent-frontend-shell/extensions/manifest.json +6 -0
- package/vendor/agent-frontend-shell/extensions/pages/ai-teammates/page.js +60 -14
- package/vendor/agent-frontend-shell/readme-simple.md +103 -0
- package/vendor/agent-frontend-shell/requirements.txt +5 -0
- package/vendor/agent-frontend-shell/server.py +7 -0
- package/vendor/agent-frontend-shell/static/boot.js +53 -1
- package/vendor/agent-frontend-shell/static/commands.js +20 -10
- package/vendor/agent-frontend-shell/static/i18n.js +1142 -1016
- package/vendor/agent-frontend-shell/static/index.html +13 -3
- package/vendor/agent-frontend-shell/static/messages.js +48 -3
- package/vendor/agent-frontend-shell/static/panels.js +199 -30
- package/vendor/agent-frontend-shell/static/sessions.js +249 -39
- package/vendor/agent-frontend-shell/static/style.css +46 -2
- package/vendor/agent-frontend-shell/static/ui.js +323 -79
- package/vendor/agent-frontend-shell/static/workspace.js +185 -7
- package/vendor/agent-frontend-shell/README-CUSTOM.md +0 -76
- package/vendor/agent-frontend-shell/docker-compose.custom.yml +0 -26
|
@@ -671,75 +671,233 @@ def validate_workspace_to_add(path: str) -> Path:
|
|
|
671
671
|
def safe_resolve_ws(root: Path, requested: str) -> Path:
|
|
672
672
|
"""Resolve a relative path inside a workspace root, raising ValueError on traversal.
|
|
673
673
|
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
the
|
|
674
|
+
Both raw ``..`` traversal and symlink escapes are blocked. Workspace file
|
|
675
|
+
APIs can be reached by browser UI actions and agent/tool calls, so a symlink
|
|
676
|
+
inside the workspace must not expand the trusted workspace boundary to an
|
|
677
|
+
arbitrary host path.
|
|
677
678
|
"""
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
resolved = unresolved.resolve()
|
|
681
|
-
# Fast path: resolved path is inside root (covers most cases)
|
|
679
|
+
root_resolved = root.resolve()
|
|
680
|
+
resolved = (root / requested).resolve()
|
|
682
681
|
try:
|
|
683
|
-
resolved.relative_to(
|
|
684
|
-
return resolved
|
|
685
|
-
except ValueError:
|
|
686
|
-
pass
|
|
687
|
-
# Symlink path: normalize '..' (without following symlinks) and check
|
|
688
|
-
# os.path.normpath collapses '..' but does NOT follow symlinks.
|
|
689
|
-
norm = Path(os.path.normpath(str(unresolved)))
|
|
690
|
-
try:
|
|
691
|
-
norm.relative_to(root)
|
|
682
|
+
resolved.relative_to(root_resolved)
|
|
692
683
|
except ValueError:
|
|
693
684
|
raise ValueError(f"Path traversal blocked: {requested}")
|
|
694
|
-
# Symlink points outside workspace root — additionally block system directories.
|
|
695
|
-
# Even if the user placed the symlink intentionally, prevent reads from
|
|
696
|
-
# /etc, /proc, /sys, /dev and other blocked roots (LLM agents can call
|
|
697
|
-
# read_file_content via tool calls, not just human users).
|
|
698
|
-
if _is_blocked_system_path(resolved):
|
|
699
|
-
raise ValueError(f"Path traversal blocked (system dir): {requested}")
|
|
700
685
|
return resolved
|
|
701
686
|
|
|
702
687
|
|
|
688
|
+
# ── Race-safe (TOCTOU) anchored open ─────────────────────────────────────────
|
|
689
|
+
# safe_resolve_ws() validates a path, but if callers then re-open by pathname a
|
|
690
|
+
# symlink swapped in AFTER the check could still escape the workspace. To close
|
|
691
|
+
# that window we open the (already symlink-resolved) target component-by-component
|
|
692
|
+
# from the workspace root using openat (dir_fd) + O_NOFOLLOW: every component must
|
|
693
|
+
# be a real, non-symlink entry, so a component swapped to a symlink mid-flight is
|
|
694
|
+
# refused. Legit in-workspace symlinks still work because safe_resolve_ws() has
|
|
695
|
+
# already collapsed them to their real in-workspace target, and we walk that real
|
|
696
|
+
# (symlink-free) path. Portable: uses os.supports_dir_fd where available (Linux,
|
|
697
|
+
# macOS); on platforms without dir_fd support (Windows — where creating symlinks
|
|
698
|
+
# also requires admin) we fall back to a plain pathname open, matching the prior
|
|
699
|
+
# behaviour with no regression.
|
|
700
|
+
|
|
701
|
+
_DIR_FD_OK = os.open in getattr(os, "supports_dir_fd", set())
|
|
702
|
+
_O_NOFOLLOW = getattr(os, "O_NOFOLLOW", 0)
|
|
703
|
+
_O_DIRECTORY = getattr(os, "O_DIRECTORY", 0)
|
|
704
|
+
|
|
705
|
+
|
|
706
|
+
def open_anchored_fd(workspace: Path, target: Path, *, want_dir: bool) -> int:
|
|
707
|
+
"""Open ``target`` race-safely and return an owned file descriptor.
|
|
708
|
+
|
|
709
|
+
``target`` must be the symlink-resolved path returned by safe_resolve_ws()
|
|
710
|
+
(i.e. already verified to live under the workspace). Raises FileNotFoundError
|
|
711
|
+
if a component is missing / wrong-type, or ValueError if a component was
|
|
712
|
+
swapped to a symlink (escape attempt). Caller owns and must close the fd.
|
|
713
|
+
"""
|
|
714
|
+
root_resolved = workspace.resolve()
|
|
715
|
+
# Relative, symlink-free component list (resolve() already collapsed any links).
|
|
716
|
+
try:
|
|
717
|
+
rel_parts = target.relative_to(root_resolved).parts
|
|
718
|
+
except ValueError:
|
|
719
|
+
raise ValueError(f"Path traversal blocked: {target}") from None
|
|
720
|
+
|
|
721
|
+
if not _DIR_FD_OK:
|
|
722
|
+
# Windows / no openat: fall back to a plain pathname open. No new race
|
|
723
|
+
# protection, but no regression vs the prior path-based behaviour, and
|
|
724
|
+
# symlink creation needs admin on Windows anyway.
|
|
725
|
+
flags = os.O_RDONLY | (_O_DIRECTORY if want_dir else 0) | _O_NOFOLLOW
|
|
726
|
+
try:
|
|
727
|
+
return os.open(str(target), flags)
|
|
728
|
+
except OSError:
|
|
729
|
+
raise FileNotFoundError(f"Not found: {target}") from None
|
|
730
|
+
|
|
731
|
+
# Open the (trusted) workspace root. root_resolved is canonical (resolve()
|
|
732
|
+
# collapsed any symlinks to REACH it, e.g. macOS /tmp -> /private/tmp), so its
|
|
733
|
+
# final component is legitimately a real directory — O_NOFOLLOW here only fires
|
|
734
|
+
# if the root itself was raced into a symlink after resolve() (escape attempt).
|
|
735
|
+
fd = os.open(str(root_resolved), os.O_RDONLY | _O_DIRECTORY | _O_NOFOLLOW)
|
|
736
|
+
try:
|
|
737
|
+
for i, part in enumerate(rel_parts):
|
|
738
|
+
is_last = i == len(rel_parts) - 1
|
|
739
|
+
want_directory = (not is_last) or want_dir
|
|
740
|
+
flags = os.O_RDONLY | _O_NOFOLLOW | (_O_DIRECTORY if want_directory else 0)
|
|
741
|
+
try:
|
|
742
|
+
nfd = os.open(part, flags, dir_fd=fd)
|
|
743
|
+
except OSError:
|
|
744
|
+
# ELOOP (component is a symlink — swapped in) or missing/wrong type.
|
|
745
|
+
raise FileNotFoundError(f"Not found: {target}") from None
|
|
746
|
+
os.close(fd)
|
|
747
|
+
fd = nfd
|
|
748
|
+
return fd
|
|
749
|
+
except BaseException:
|
|
750
|
+
try:
|
|
751
|
+
os.close(fd)
|
|
752
|
+
except OSError:
|
|
753
|
+
pass
|
|
754
|
+
raise
|
|
755
|
+
|
|
756
|
+
|
|
757
|
+
def open_anchored_create_fd(root: Path, dest: Path) -> int:
|
|
758
|
+
"""Create ``dest`` for exclusive writing race-safely, anchored under ``root``.
|
|
759
|
+
|
|
760
|
+
Walks from ``root`` via openat + O_NOFOLLOW (creating missing intermediate
|
|
761
|
+
directories with mkdir(dir_fd=...)), then creates the leaf with
|
|
762
|
+
O_CREAT|O_EXCL|O_NOFOLLOW so a symlink raced into any component cannot
|
|
763
|
+
redirect the write outside ``root``. ``dest`` must be the resolved path and
|
|
764
|
+
must not already exist (callers dedup first). Raises ValueError if ``dest``
|
|
765
|
+
is not under ``root``, FileExistsError if it exists, FileNotFoundError if a
|
|
766
|
+
component was swapped to a symlink. Caller owns and must close the returned
|
|
767
|
+
write fd. On platforms without dir_fd support (Windows) falls back to a plain
|
|
768
|
+
exclusive create — no new race protection but no regression.
|
|
769
|
+
"""
|
|
770
|
+
root_resolved = root.resolve()
|
|
771
|
+
try:
|
|
772
|
+
rel_parts = dest.relative_to(root_resolved).parts
|
|
773
|
+
except ValueError:
|
|
774
|
+
raise ValueError(f"Path traversal blocked: {dest}") from None
|
|
775
|
+
if not rel_parts:
|
|
776
|
+
raise ValueError(f"Invalid destination: {dest}")
|
|
777
|
+
|
|
778
|
+
if not _DIR_FD_OK:
|
|
779
|
+
# Windows / no openat: create parent dirs then exclusively create the leaf.
|
|
780
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
781
|
+
return os.open(str(dest), os.O_WRONLY | os.O_CREAT | os.O_EXCL | _O_NOFOLLOW, 0o644)
|
|
782
|
+
|
|
783
|
+
fd = os.open(str(root_resolved), os.O_RDONLY | _O_DIRECTORY | _O_NOFOLLOW)
|
|
784
|
+
try:
|
|
785
|
+
for part in rel_parts[:-1]:
|
|
786
|
+
try:
|
|
787
|
+
nfd = os.open(part, os.O_RDONLY | _O_DIRECTORY | _O_NOFOLLOW, dir_fd=fd)
|
|
788
|
+
except FileNotFoundError:
|
|
789
|
+
os.mkdir(part, 0o755, dir_fd=fd)
|
|
790
|
+
nfd = os.open(part, os.O_RDONLY | _O_DIRECTORY | _O_NOFOLLOW, dir_fd=fd)
|
|
791
|
+
except OSError:
|
|
792
|
+
# ELOOP — component swapped to a symlink (escape attempt).
|
|
793
|
+
raise FileNotFoundError(f"Not found: {dest}") from None
|
|
794
|
+
os.close(fd)
|
|
795
|
+
fd = nfd
|
|
796
|
+
return os.open(
|
|
797
|
+
rel_parts[-1],
|
|
798
|
+
os.O_WRONLY | os.O_CREAT | os.O_EXCL | _O_NOFOLLOW,
|
|
799
|
+
0o644,
|
|
800
|
+
dir_fd=fd,
|
|
801
|
+
)
|
|
802
|
+
finally:
|
|
803
|
+
try:
|
|
804
|
+
os.close(fd)
|
|
805
|
+
except OSError:
|
|
806
|
+
pass
|
|
807
|
+
|
|
808
|
+
|
|
809
|
+
def make_anchored_dir(root: Path, dest: Path) -> None:
|
|
810
|
+
"""Create directory ``dest`` (and any missing parents) race-safely under ``root``.
|
|
811
|
+
|
|
812
|
+
Walks from ``root`` via openat + O_NOFOLLOW, creating each missing component
|
|
813
|
+
with mkdir(dir_fd=...), so a symlink raced into any component cannot make the
|
|
814
|
+
server create directories outside ``root``. Idempotent (existing dirs are
|
|
815
|
+
fine). Raises ValueError if ``dest`` is not under ``root``, FileNotFoundError
|
|
816
|
+
if a component was swapped to a symlink. On platforms without dir_fd support
|
|
817
|
+
(Windows) falls back to a plain Path.mkdir — no regression.
|
|
818
|
+
"""
|
|
819
|
+
root_resolved = root.resolve()
|
|
820
|
+
dest_resolved = dest.resolve()
|
|
821
|
+
if dest_resolved == root_resolved:
|
|
822
|
+
return
|
|
823
|
+
try:
|
|
824
|
+
rel_parts = dest_resolved.relative_to(root_resolved).parts
|
|
825
|
+
except ValueError:
|
|
826
|
+
raise ValueError(f"Path traversal blocked: {dest}") from None
|
|
827
|
+
|
|
828
|
+
if not _DIR_FD_OK:
|
|
829
|
+
dest.mkdir(parents=True, exist_ok=True)
|
|
830
|
+
return
|
|
831
|
+
|
|
832
|
+
fd = os.open(str(root_resolved), os.O_RDONLY | _O_DIRECTORY | _O_NOFOLLOW)
|
|
833
|
+
try:
|
|
834
|
+
for part in rel_parts:
|
|
835
|
+
try:
|
|
836
|
+
nfd = os.open(part, os.O_RDONLY | _O_DIRECTORY | _O_NOFOLLOW, dir_fd=fd)
|
|
837
|
+
except FileNotFoundError:
|
|
838
|
+
os.mkdir(part, 0o755, dir_fd=fd)
|
|
839
|
+
nfd = os.open(part, os.O_RDONLY | _O_DIRECTORY | _O_NOFOLLOW, dir_fd=fd)
|
|
840
|
+
except OSError:
|
|
841
|
+
# ELOOP — component swapped to a symlink (escape attempt).
|
|
842
|
+
raise FileNotFoundError(f"Not found: {dest}") from None
|
|
843
|
+
os.close(fd)
|
|
844
|
+
fd = nfd
|
|
845
|
+
finally:
|
|
846
|
+
try:
|
|
847
|
+
os.close(fd)
|
|
848
|
+
except OSError:
|
|
849
|
+
pass
|
|
850
|
+
|
|
851
|
+
|
|
703
852
|
def list_dir(workspace: Path, rel: str='.'):
|
|
704
853
|
target = safe_resolve_ws(workspace, rel)
|
|
705
854
|
if not target.is_dir():
|
|
706
855
|
raise FileNotFoundError(f"Not a directory: {rel}")
|
|
707
856
|
ws_resolved = workspace.resolve()
|
|
857
|
+
target_resolved = target.resolve()
|
|
708
858
|
entries = []
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
859
|
+
|
|
860
|
+
def _process(name, is_symlink, raw_link, lstat_result, reachable):
|
|
861
|
+
"""Append one directory entry. ``raw_link`` is the os.readlink() result
|
|
862
|
+
for symlinks (else None); ``lstat_result`` is an os.stat_result obtained
|
|
863
|
+
with follow_symlinks=False (else None); ``reachable`` is False when a
|
|
864
|
+
follow_symlinks=True stat raised (broken target or symlink loop)."""
|
|
865
|
+
if is_symlink:
|
|
866
|
+
if raw_link is None:
|
|
867
|
+
return
|
|
868
|
+
# A symlink whose follow-stat raised (ELOOP / broken target) can never
|
|
869
|
+
# be opened — filter it. This catches mutual/self loops portably across
|
|
870
|
+
# Python versions where Path.resolve() loop handling differs (3.11
|
|
871
|
+
# raises RuntimeError, 3.13 can return a path), so do not rely on
|
|
872
|
+
# resolve() raising for cycle detection.
|
|
873
|
+
if not reachable:
|
|
874
|
+
return
|
|
712
875
|
try:
|
|
713
|
-
link_target =
|
|
714
|
-
except OSError:
|
|
715
|
-
|
|
716
|
-
# Cycle detection: skip if symlink points back to current dir
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
if (link_target == target.resolve() or link_target == target
|
|
720
|
-
or link_target == ws_resolved):
|
|
721
|
-
continue
|
|
876
|
+
link_target = (target_resolved / raw_link).resolve()
|
|
877
|
+
except (OSError, RuntimeError):
|
|
878
|
+
return
|
|
879
|
+
# Cycle detection: skip if symlink points back to current dir or root.
|
|
880
|
+
if link_target == target_resolved or link_target == ws_resolved:
|
|
881
|
+
return
|
|
722
882
|
try:
|
|
723
|
-
|
|
724
|
-
# target is under link_target —
|
|
725
|
-
continue
|
|
883
|
+
target_resolved.relative_to(link_target)
|
|
884
|
+
return # target is under link_target — ancestor → cycle
|
|
726
885
|
except ValueError:
|
|
727
886
|
pass
|
|
728
|
-
#
|
|
887
|
+
# Hide symlinks that resolve outside the workspace (can never be opened).
|
|
888
|
+
try:
|
|
889
|
+
link_target.relative_to(ws_resolved)
|
|
890
|
+
except ValueError:
|
|
891
|
+
return
|
|
729
892
|
if _is_blocked_system_path(link_target):
|
|
730
|
-
|
|
893
|
+
return
|
|
731
894
|
is_dir = link_target.is_dir()
|
|
732
|
-
|
|
733
|
-
display_path = str(Path(item.name))
|
|
895
|
+
display_path = name
|
|
734
896
|
if rel and rel != '.':
|
|
735
897
|
display_path = rel + '/' + display_path
|
|
736
|
-
|
|
737
|
-
item_stat = item.lstat()
|
|
738
|
-
mtime_ns = item_stat.st_mtime_ns
|
|
739
|
-
except OSError:
|
|
740
|
-
mtime_ns = None
|
|
898
|
+
mtime_ns = lstat_result.st_mtime_ns if lstat_result is not None else None
|
|
741
899
|
entry = {
|
|
742
|
-
'name':
|
|
900
|
+
'name': name,
|
|
743
901
|
'path': display_path,
|
|
744
902
|
'type': 'symlink',
|
|
745
903
|
'target': str(link_target),
|
|
@@ -753,27 +911,118 @@ def list_dir(workspace: Path, rel: str='.'):
|
|
|
753
911
|
entry['size'] = None
|
|
754
912
|
entries.append(entry)
|
|
755
913
|
else:
|
|
756
|
-
|
|
757
|
-
# the workspace root) still get a valid workspace-relative path.
|
|
758
|
-
entry_path = item.name
|
|
914
|
+
entry_path = name
|
|
759
915
|
if rel and rel != '.':
|
|
760
|
-
entry_path = rel + '/' +
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
size =
|
|
764
|
-
mtime_ns =
|
|
765
|
-
|
|
916
|
+
entry_path = rel + '/' + name
|
|
917
|
+
if lstat_result is not None:
|
|
918
|
+
is_file = stat.S_ISREG(lstat_result.st_mode)
|
|
919
|
+
size = lstat_result.st_size if is_file else None
|
|
920
|
+
mtime_ns = lstat_result.st_mtime_ns
|
|
921
|
+
is_dir_entry = stat.S_ISDIR(lstat_result.st_mode)
|
|
922
|
+
else:
|
|
766
923
|
size = None
|
|
767
924
|
mtime_ns = None
|
|
925
|
+
is_dir_entry = False
|
|
768
926
|
entries.append({
|
|
769
|
-
'name':
|
|
927
|
+
'name': name,
|
|
770
928
|
'path': entry_path,
|
|
771
|
-
'type': 'dir' if
|
|
929
|
+
'type': 'dir' if is_dir_entry else 'file',
|
|
772
930
|
'size': size,
|
|
773
931
|
'mtime_ns': mtime_ns,
|
|
774
932
|
})
|
|
775
|
-
|
|
776
|
-
|
|
933
|
+
|
|
934
|
+
if _DIR_FD_OK:
|
|
935
|
+
# #3398 TOCTOU hardening (Linux/macOS): open the directory via an anchored
|
|
936
|
+
# openat-walk (O_NOFOLLOW on every component) and enumerate via the verified
|
|
937
|
+
# fd (os.scandir(fd) + fd-relative fstatat/readlinkat), so a path component
|
|
938
|
+
# swapped to an escaping symlink after safe_resolve_ws() cannot redirect the
|
|
939
|
+
# listing.
|
|
940
|
+
def _sort_key_de(de):
|
|
941
|
+
try:
|
|
942
|
+
is_link = de.is_symlink()
|
|
943
|
+
except OSError:
|
|
944
|
+
is_link = False
|
|
945
|
+
is_file = False
|
|
946
|
+
if not is_link:
|
|
947
|
+
try:
|
|
948
|
+
is_file = de.is_file()
|
|
949
|
+
except OSError:
|
|
950
|
+
pass
|
|
951
|
+
return (not is_link, is_file, de.name.lower())
|
|
952
|
+
|
|
953
|
+
dir_fd = open_anchored_fd(workspace, target, want_dir=True)
|
|
954
|
+
try:
|
|
955
|
+
st = os.fstat(dir_fd)
|
|
956
|
+
if not stat.S_ISDIR(st.st_mode):
|
|
957
|
+
raise FileNotFoundError(f"Not a directory: {rel}")
|
|
958
|
+
with os.scandir(dir_fd) as scan:
|
|
959
|
+
scandir_entries = sorted(scan, key=_sort_key_de)
|
|
960
|
+
for de in scandir_entries:
|
|
961
|
+
name = de.name
|
|
962
|
+
is_symlink = de.is_symlink()
|
|
963
|
+
raw_link = None
|
|
964
|
+
if is_symlink:
|
|
965
|
+
try:
|
|
966
|
+
raw_link = os.readlink(name, dir_fd=dir_fd)
|
|
967
|
+
except OSError:
|
|
968
|
+
raw_link = None
|
|
969
|
+
try:
|
|
970
|
+
lst = os.stat(name, dir_fd=dir_fd, follow_symlinks=False)
|
|
971
|
+
except OSError:
|
|
972
|
+
lst = None
|
|
973
|
+
# reachable: follow-stat succeeds (filters ELOOP/broken symlinks).
|
|
974
|
+
reachable = True
|
|
975
|
+
if is_symlink:
|
|
976
|
+
try:
|
|
977
|
+
os.stat(name, dir_fd=dir_fd, follow_symlinks=True)
|
|
978
|
+
except OSError:
|
|
979
|
+
reachable = False
|
|
980
|
+
_process(name, is_symlink, raw_link, lst, reachable)
|
|
981
|
+
if len(entries) >= 200:
|
|
982
|
+
break
|
|
983
|
+
finally:
|
|
984
|
+
try:
|
|
985
|
+
os.close(dir_fd)
|
|
986
|
+
except OSError:
|
|
987
|
+
pass
|
|
988
|
+
else:
|
|
989
|
+
# Portability fallback (Windows / no dir_fd): path-based enumeration after
|
|
990
|
+
# safe_resolve_ws(). No anchored-fd race protection on these platforms, but
|
|
991
|
+
# no regression vs the prior behaviour (creating symlinks on Windows needs
|
|
992
|
+
# admin anyway), and safe_resolve_ws() still blocks the static escape.
|
|
993
|
+
def _sort_key_p(p: Path):
|
|
994
|
+
is_link = p.is_symlink()
|
|
995
|
+
is_file = False
|
|
996
|
+
if not is_link:
|
|
997
|
+
try:
|
|
998
|
+
is_file = p.is_file()
|
|
999
|
+
except OSError:
|
|
1000
|
+
pass
|
|
1001
|
+
return (not is_link, is_file, p.name.lower())
|
|
1002
|
+
|
|
1003
|
+
for item in sorted(target.iterdir(), key=_sort_key_p):
|
|
1004
|
+
name = item.name
|
|
1005
|
+
is_symlink = item.is_symlink()
|
|
1006
|
+
raw_link = None
|
|
1007
|
+
if is_symlink:
|
|
1008
|
+
try:
|
|
1009
|
+
raw_link = os.readlink(str(item))
|
|
1010
|
+
except OSError:
|
|
1011
|
+
raw_link = None
|
|
1012
|
+
try:
|
|
1013
|
+
lst = item.lstat()
|
|
1014
|
+
except OSError:
|
|
1015
|
+
lst = None
|
|
1016
|
+
# reachable: follow-stat succeeds (filters ELOOP/broken symlinks).
|
|
1017
|
+
reachable = True
|
|
1018
|
+
if is_symlink:
|
|
1019
|
+
try:
|
|
1020
|
+
os.stat(str(item), follow_symlinks=True)
|
|
1021
|
+
except OSError:
|
|
1022
|
+
reachable = False
|
|
1023
|
+
_process(name, is_symlink, raw_link, lst, reachable)
|
|
1024
|
+
if len(entries) >= 200:
|
|
1025
|
+
break
|
|
777
1026
|
return entries
|
|
778
1027
|
|
|
779
1028
|
|
|
@@ -805,11 +1054,20 @@ def read_file_content(workspace: Path, rel: str) -> dict:
|
|
|
805
1054
|
target = safe_resolve_ws(workspace, rel)
|
|
806
1055
|
if not target.is_file():
|
|
807
1056
|
raise FileNotFoundError(f"Not a file: {rel}")
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
1057
|
+
# #3398 TOCTOU hardening: open the resolved file via an anchored openat-walk
|
|
1058
|
+
# (O_NOFOLLOW on every component) so a path swapped to an escaping symlink
|
|
1059
|
+
# after safe_resolve_ws() cannot be followed, then read from the fd (not the
|
|
1060
|
+
# pathname) so the bytes returned are guaranteed to be the verified file.
|
|
1061
|
+
fd = open_anchored_fd(workspace, target, want_dir=False)
|
|
1062
|
+
with os.fdopen(fd, 'rb', closefd=True) as fh:
|
|
1063
|
+
st = os.fstat(fh.fileno())
|
|
1064
|
+
if not stat.S_ISREG(st.st_mode):
|
|
1065
|
+
raise FileNotFoundError(f"Not a file: {rel}")
|
|
1066
|
+
if st.st_size > MAX_FILE_BYTES:
|
|
1067
|
+
raise ValueError(f"File too large ({st.st_size} bytes, max {MAX_FILE_BYTES})")
|
|
1068
|
+
raw = fh.read(MAX_FILE_BYTES + 1)
|
|
1069
|
+
content = raw.decode('utf-8', errors='replace')
|
|
1070
|
+
return {'path': rel, 'content': content, 'size': len(raw), 'lines': content.count('\n') + 1}
|
|
813
1071
|
|
|
814
1072
|
|
|
815
1073
|
# ── Git detection ──────────────────────────────────────────────────────────
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
# Installation and Startup of BitSeek Claw
|
|
2
|
+
|
|
3
|
+
This guide explains how to install and start BitSeek Claw (Hermes WebUI).
|
|
4
|
+
|
|
5
|
+
## System Requirements
|
|
6
|
+
|
|
7
|
+
- **Operating System**: macOS, Linux, or WSL2 (Windows requires WSL2)
|
|
8
|
+
- **Python**: 3.8 or higher
|
|
9
|
+
- **Hermes Agent**: Requires pre-installed Hermes Agent
|
|
10
|
+
|
|
11
|
+
## Installation Steps
|
|
12
|
+
|
|
13
|
+
### 1. Clone the Repository
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
git clone https://github.com/your-repo/agent-frontend-shell.git
|
|
17
|
+
cd agent-frontend-shell
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
### 2. Environment Configuration
|
|
21
|
+
|
|
22
|
+
#### Method 1: Using `.env` File (Recommended)
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
# Copy the example environment file
|
|
26
|
+
cp .env.example .env
|
|
27
|
+
|
|
28
|
+
# Edit the .env file to configure necessary parameters
|
|
29
|
+
nano .env
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
#### Method 2: Setting Environment Variables Directly
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
export HERMES_WEBUI_HOST=127.0.0.1
|
|
36
|
+
export HERMES_WEBUI_PORT=8787
|
|
37
|
+
export HERMES_HOME=~/.hermes
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
### 3. Starting the Service
|
|
41
|
+
|
|
42
|
+
#### Method 1: Using Startup Script (Recommended)
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
# Start using start.sh
|
|
46
|
+
./start.sh
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
#### Method 2: Using Python Launcher
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
# Start using bootstrap.py (automatically checks dependencies)
|
|
53
|
+
python3 bootstrap.py
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
#### Method 3: Using Control Script
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
# Manage service using ctl.sh
|
|
60
|
+
./ctl.sh start
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Startup Parameters
|
|
64
|
+
|
|
65
|
+
### Environment Variable Configuration
|
|
66
|
+
|
|
67
|
+
| Variable Name | Default Value | Description |
|
|
68
|
+
|---------------|---------------|-------------|
|
|
69
|
+
| `HERMES_WEBUI_HOST` | `127.0.0.1` | Listening address |
|
|
70
|
+
| `HERMES_WEBUI_PORT` | `8787` | Listening port |
|
|
71
|
+
| `HERMES_HOME` | `~/.hermes` | Hermes main directory |
|
|
72
|
+
| `HERMES_WEBUI_STATE_DIR` | - | State directory path |
|
|
73
|
+
| `HERMES_WEBUI_DEFAULT_WORKSPACE` | - | Default workspace path |
|
|
74
|
+
|
|
75
|
+
### Startup Options
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
# Start with specific port
|
|
79
|
+
HERMES_WEBUI_PORT=8888 ./start.sh
|
|
80
|
+
|
|
81
|
+
# Start with specific host and port
|
|
82
|
+
HERMES_WEBUI_HOST=0.0.0.0 HERMES_WEBUI_PORT=8787 ./start.sh
|
|
83
|
+
|
|
84
|
+
# Start with custom state directory
|
|
85
|
+
HERMES_WEBUI_STATE_DIR=/tmp/hermes-state ./start.sh
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## Accessing the Interface
|
|
89
|
+
|
|
90
|
+
After successful startup, access in your browser:
|
|
91
|
+
|
|
92
|
+
```
|
|
93
|
+
http://localhost:8787
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
### First-time Access
|
|
97
|
+
|
|
98
|
+
1. Open your browser and navigate to the above address
|
|
99
|
+
2. First-time access will enter the **First Run Wizard**
|
|
100
|
+
3. Follow the wizard prompts to configure:
|
|
101
|
+
- AI provider settings (e.g., OpenAI, Anthropic, etc.)
|
|
102
|
+
- API key configuration
|
|
103
|
+
- Basic preference settings
|
|
104
|
+
|
|
105
|
+
## Docker Deployment
|
|
106
|
+
|
|
107
|
+
### Single Container Deployment
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
# Using Docker Compose
|
|
111
|
+
docker-compose up -d
|
|
112
|
+
|
|
113
|
+
# Or using custom configuration
|
|
114
|
+
docker-compose -f docker-compose.custom.yml up -d
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
### Multi-container Deployment
|
|
118
|
+
|
|
119
|
+
```bash
|
|
120
|
+
# Three-container architecture
|
|
121
|
+
docker-compose -f docker-compose.three-container.yml up -d
|
|
122
|
+
|
|
123
|
+
# Two-container architecture
|
|
124
|
+
docker-compose -f docker-compose.two-container.yml up -d
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
## Common Issues
|
|
128
|
+
|
|
129
|
+
### Port in Use
|
|
130
|
+
|
|
131
|
+
```bash
|
|
132
|
+
# Check port usage
|
|
133
|
+
lsof -i :8787
|
|
134
|
+
|
|
135
|
+
# Use a different port
|
|
136
|
+
HERMES_WEBUI_PORT=8788 ./start.sh
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
### Permission Issues
|
|
140
|
+
|
|
141
|
+
```bash
|
|
142
|
+
# Ensure scripts have execute permissions
|
|
143
|
+
chmod +x start.sh
|
|
144
|
+
chmod +x ctl.sh
|
|
145
|
+
chmod +x bootstrap.py
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
### Missing Dependencies
|
|
149
|
+
|
|
150
|
+
```bash
|
|
151
|
+
# Install Python dependencies
|
|
152
|
+
pip install -r requirements.txt
|
|
153
|
+
|
|
154
|
+
# Or use a virtual environment
|
|
155
|
+
python3 -m venv venv
|
|
156
|
+
source venv/bin/activate
|
|
157
|
+
pip install -r requirements.txt
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
## Stopping the Service
|
|
161
|
+
|
|
162
|
+
```bash
|
|
163
|
+
# Stop using control script
|
|
164
|
+
./ctl.sh stop
|
|
165
|
+
|
|
166
|
+
# Or find and terminate the process
|
|
167
|
+
pkill -f "python.*server.py"
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
## Next Steps
|
|
171
|
+
|
|
172
|
+
After installation and startup, please refer to:
|
|
173
|
+
- **[Overview](01-Overview.md)** - Understand the interface layout
|
|
174
|
+
- **[Page Operations](02-Page-Operations.md)** - Learn specific operations
|