@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.
Files changed (99) hide show
  1. package/package.json +2 -2
  2. package/vendor/agent-frontend-shell/.bitseek-source.json +2 -2
  3. package/vendor/agent-frontend-shell/CHANGELOG.md +178 -1
  4. package/vendor/agent-frontend-shell/CONTRIBUTORS.md +5 -5
  5. package/vendor/agent-frontend-shell/api/agent_health.py +134 -0
  6. package/vendor/agent-frontend-shell/api/config.py +145 -104
  7. package/vendor/agent-frontend-shell/api/gateway_chat.py +56 -12
  8. package/vendor/agent-frontend-shell/api/helpers.py +4 -2
  9. package/vendor/agent-frontend-shell/api/models.py +202 -20
  10. package/vendor/agent-frontend-shell/api/paths.py +77 -0
  11. package/vendor/agent-frontend-shell/api/plugins.py +185 -0
  12. package/vendor/agent-frontend-shell/api/profiles.py +95 -16
  13. package/vendor/agent-frontend-shell/api/routes.py +831 -30
  14. package/vendor/agent-frontend-shell/api/run_journal.py +1 -0
  15. package/vendor/agent-frontend-shell/api/state_sync.py +5 -4
  16. package/vendor/agent-frontend-shell/api/streaming.py +211 -56
  17. package/vendor/agent-frontend-shell/api/todo_state.py +122 -0
  18. package/vendor/agent-frontend-shell/api/updates.py +30 -3
  19. package/vendor/agent-frontend-shell/api/upload.py +251 -18
  20. package/vendor/agent-frontend-shell/api/workspace.py +323 -65
  21. package/vendor/agent-frontend-shell/bitseek_docs/BitSeek_Claw_Operation_Manual_EN.docx +0 -0
  22. package/vendor/agent-frontend-shell/bitseek_docs/BitSeek_Claw_Operation_Manual_ZH.docx +0 -0
  23. package/vendor/agent-frontend-shell/bitseek_docs/en/00-Installation.md +174 -0
  24. package/vendor/agent-frontend-shell/bitseek_docs/en/01-Overview.md +128 -0
  25. package/vendor/agent-frontend-shell/bitseek_docs/en/02-Page-Operations.md +461 -0
  26. package/vendor/agent-frontend-shell/bitseek_docs/en/README.md +61 -0
  27. package/vendor/agent-frontend-shell/bitseek_docs/en/images/ai-colleagues.png +0 -0
  28. package/vendor/agent-frontend-shell/bitseek_docs/en/images/chat-area.png +0 -0
  29. package/vendor/agent-frontend-shell/bitseek_docs/en/images/kanban.png +0 -0
  30. package/vendor/agent-frontend-shell/bitseek_docs/en/images/main-page.png +0 -0
  31. package/vendor/agent-frontend-shell/bitseek_docs/en/images/memory-notes.png +0 -0
  32. package/vendor/agent-frontend-shell/bitseek_docs/en/images/memory-overview.png +0 -0
  33. package/vendor/agent-frontend-shell/bitseek_docs/en/images/memory-profile.png +0 -0
  34. package/vendor/agent-frontend-shell/bitseek_docs/en/images/memory-soul.png +0 -0
  35. package/vendor/agent-frontend-shell/bitseek_docs/en/images/memory.png +0 -0
  36. package/vendor/agent-frontend-shell/bitseek_docs/en/images/navigation-bar.png +0 -0
  37. package/vendor/agent-frontend-shell/bitseek_docs/en/images/settings-appearance.png +0 -0
  38. package/vendor/agent-frontend-shell/bitseek_docs/en/images/settings-conversation.png +0 -0
  39. package/vendor/agent-frontend-shell/bitseek_docs/en/images/settings-overview.png +0 -0
  40. package/vendor/agent-frontend-shell/bitseek_docs/en/images/settings-plugins.png +0 -0
  41. package/vendor/agent-frontend-shell/bitseek_docs/en/images/settings-preferences.png +0 -0
  42. package/vendor/agent-frontend-shell/bitseek_docs/en/images/settings-providers.png +0 -0
  43. package/vendor/agent-frontend-shell/bitseek_docs/en/images/settings-system.png +0 -0
  44. package/vendor/agent-frontend-shell/bitseek_docs/en/images/settings.png +0 -0
  45. package/vendor/agent-frontend-shell/bitseek_docs/en/images/sidebar.png +0 -0
  46. package/vendor/agent-frontend-shell/bitseek_docs/en/images/skills.png +0 -0
  47. package/vendor/agent-frontend-shell/bitseek_docs/en/images/tasks.png +0 -0
  48. package/vendor/agent-frontend-shell/bitseek_docs/en/images/workspace-panel.png +0 -0
  49. package/vendor/agent-frontend-shell/bitseek_docs/md_to_docx.py +351 -0
  50. package/vendor/agent-frontend-shell/bitseek_docs/zh/00-/345/256/211/350/243/205/345/220/257/345/212/250.md +174 -0
  51. package/vendor/agent-frontend-shell/bitseek_docs/zh/01-/346/225/264/344/275/223/346/246/202/350/247/210.md +128 -0
  52. package/vendor/agent-frontend-shell/bitseek_docs/zh/02-/351/241/265/351/235/242/346/223/215/344/275/234.md +463 -0
  53. package/vendor/agent-frontend-shell/bitseek_docs/zh/README.md +61 -0
  54. package/vendor/agent-frontend-shell/bitseek_docs/zh/images/ai-colleagues.png +0 -0
  55. package/vendor/agent-frontend-shell/bitseek_docs/zh/images/chat-area.png +0 -0
  56. package/vendor/agent-frontend-shell/bitseek_docs/zh/images/kanban.png +0 -0
  57. package/vendor/agent-frontend-shell/bitseek_docs/zh/images/main-page.png +0 -0
  58. package/vendor/agent-frontend-shell/bitseek_docs/zh/images/memory-notes.png +0 -0
  59. package/vendor/agent-frontend-shell/bitseek_docs/zh/images/memory-overview.png +0 -0
  60. package/vendor/agent-frontend-shell/bitseek_docs/zh/images/memory-profile.png +0 -0
  61. package/vendor/agent-frontend-shell/bitseek_docs/zh/images/memory-soul.png +0 -0
  62. package/vendor/agent-frontend-shell/bitseek_docs/zh/images/memory.png +0 -0
  63. package/vendor/agent-frontend-shell/bitseek_docs/zh/images/navigation-bar.png +0 -0
  64. package/vendor/agent-frontend-shell/bitseek_docs/zh/images/settings-appearance.png +0 -0
  65. package/vendor/agent-frontend-shell/bitseek_docs/zh/images/settings-conversation.png +0 -0
  66. package/vendor/agent-frontend-shell/bitseek_docs/zh/images/settings-overview.png +0 -0
  67. package/vendor/agent-frontend-shell/bitseek_docs/zh/images/settings-plugins.png +0 -0
  68. package/vendor/agent-frontend-shell/bitseek_docs/zh/images/settings-preferences.png +0 -0
  69. package/vendor/agent-frontend-shell/bitseek_docs/zh/images/settings-providers.png +0 -0
  70. package/vendor/agent-frontend-shell/bitseek_docs/zh/images/settings-system.png +0 -0
  71. package/vendor/agent-frontend-shell/bitseek_docs/zh/images/settings.png +0 -0
  72. package/vendor/agent-frontend-shell/bitseek_docs/zh/images/sidebar.png +0 -0
  73. package/vendor/agent-frontend-shell/bitseek_docs/zh/images/skills.png +0 -0
  74. package/vendor/agent-frontend-shell/bitseek_docs/zh/images/tasks.png +0 -0
  75. package/vendor/agent-frontend-shell/bitseek_docs/zh/images/workspace-panel.png +0 -0
  76. package/vendor/agent-frontend-shell/build-release.sh +62 -0
  77. package/vendor/agent-frontend-shell/ctl.sh +1 -0
  78. package/vendor/agent-frontend-shell/docker-compose.local.yml +33 -0
  79. package/vendor/agent-frontend-shell/docker-compose.yml +8 -0
  80. package/vendor/agent-frontend-shell/docker_init.bash +1 -0
  81. package/vendor/agent-frontend-shell/docs/rfcs/hermes-run-adapter-contract.md +74 -15
  82. package/vendor/agent-frontend-shell/extensions/common/index.css +6 -0
  83. package/vendor/agent-frontend-shell/extensions/manifest.json +6 -0
  84. package/vendor/agent-frontend-shell/extensions/pages/ai-teammates/page.js +60 -14
  85. package/vendor/agent-frontend-shell/readme-simple.md +103 -0
  86. package/vendor/agent-frontend-shell/requirements.txt +5 -0
  87. package/vendor/agent-frontend-shell/server.py +7 -0
  88. package/vendor/agent-frontend-shell/static/boot.js +53 -1
  89. package/vendor/agent-frontend-shell/static/commands.js +20 -10
  90. package/vendor/agent-frontend-shell/static/i18n.js +1142 -1016
  91. package/vendor/agent-frontend-shell/static/index.html +13 -3
  92. package/vendor/agent-frontend-shell/static/messages.js +48 -3
  93. package/vendor/agent-frontend-shell/static/panels.js +199 -30
  94. package/vendor/agent-frontend-shell/static/sessions.js +249 -39
  95. package/vendor/agent-frontend-shell/static/style.css +46 -2
  96. package/vendor/agent-frontend-shell/static/ui.js +323 -79
  97. package/vendor/agent-frontend-shell/static/workspace.js +185 -7
  98. package/vendor/agent-frontend-shell/README-CUSTOM.md +0 -76
  99. 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
- Symlinks whose *unresolved* path is within the workspace root are allowed
675
- the user placed them there intentionally. Only raw ``..`` traversal outside
676
- the root is blocked.
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
- import os
679
- unresolved = root / requested
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(root.resolve())
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
- for item in sorted(target.iterdir(), key=lambda p: (not p.is_symlink(), p.is_file(), p.name.lower())):
710
- if item.is_symlink():
711
- # Resolve the symlink target and check if it stays within workspace
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 = item.resolve()
714
- except OSError:
715
- continue
716
- # Cycle detection: skip if symlink points back to current dir,
717
- # workspace root, or any ancestor of current dir.
718
- # This must run REGARDLESS of whether target is inside workspace.
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
- target.resolve().relative_to(link_target)
724
- # target is under link_target — link_target is an ancestor → cycle
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
- # Block symlinks that resolve to system directories.
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
- continue
893
+ return
731
894
  is_dir = link_target.is_dir()
732
- # Keep the display path relative to workspace (don't follow the link)
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
- try:
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': item.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
- # Use rel-based path so entries under symlink targets (outside
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 + '/' + item.name
761
- try:
762
- item_stat = item.stat()
763
- size = item_stat.st_size if item.is_file() else None
764
- mtime_ns = item_stat.st_mtime_ns
765
- except OSError:
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': item.name,
927
+ 'name': name,
770
928
  'path': entry_path,
771
- 'type': 'dir' if item.is_dir() else 'file',
929
+ 'type': 'dir' if is_dir_entry else 'file',
772
930
  'size': size,
773
931
  'mtime_ns': mtime_ns,
774
932
  })
775
- if len(entries) >= 200:
776
- break
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
- size = target.stat().st_size
809
- if size > MAX_FILE_BYTES:
810
- raise ValueError(f"File too large ({size} bytes, max {MAX_FILE_BYTES})")
811
- content = target.read_text(encoding='utf-8', errors='replace')
812
- return {'path': rel, 'content': content, 'size': size, 'lines': content.count('\n') + 1}
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 ──────────────────────────────────────────────────────────
@@ -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