Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
be3c18a
Bound requires-python below 3.14 and pin the interpreter
david-hummingbot Sep 18, 2026
2bd9b81
Manage line endings, because some of them are hashed
david-hummingbot Sep 18, 2026
acfa2ac
Digest agent content over normalized newlines
david-hummingbot Sep 18, 2026
198e27b
Preserve the fork stamp on agent and strategy saves
david-hummingbot Sep 18, 2026
ff84a77
Detect forks whose stock counterpart has moved, or gone
david-hummingbot Sep 18, 2026
019309e
Warn when local forks will shadow an update's changes
david-hummingbot Sep 18, 2026
320c3bd
Refuse to update a detached checkout, and name a stale git lock
david-hummingbot Sep 18, 2026
ad5c59c
Stop reporting an operator's own image build as behind
david-hummingbot Sep 18, 2026
183e0ec
Only offer an image update when the image can match the checkout
david-hummingbot Sep 18, 2026
c913d01
Build the dashboard alongside the running one, not on top of it
david-hummingbot Sep 18, 2026
2614ade
Stop offering a restart that cannot work, and make a failed one readable
david-hummingbot Sep 18, 2026
27d38e2
Apply an update by restarting, instead of asking for a shell
david-hummingbot Sep 18, 2026
69619f3
Offer a way out of a conflict that keeps the work
david-hummingbot Sep 18, 2026
df39ca0
Give the chat agent's routines a local layer
david-hummingbot Sep 18, 2026
7d1663d
Check the install over, warn before it, and stop the quiet edges
david-hummingbot Sep 18, 2026
fe472f4
Keep the preflight plan in step with the run
david-hummingbot Sep 18, 2026
4a52c0c
Warn before an update overrides your files, and cover the ones with n…
david-hummingbot Sep 18, 2026
6fab0e4
Say whether a timed-out docker step is safe to retry
david-hummingbot Sep 18, 2026
148f1cf
Start the relaunch countdown without an effect
david-hummingbot Sep 18, 2026
10edc3f
Name the library a stale fork belongs to
david-hummingbot Sep 29, 2026
0df5464
Scan the shared library for stale forks too
david-hummingbot Sep 29, 2026
12227b4
Compare chat routines against the library they actually shadow
david-hummingbot Sep 29, 2026
c0ad88c
Stop the update lock erasing the pid it is about to report
david-hummingbot Sep 29, 2026
96eeb56
Read the dashboard's real bind on hosts without ss
david-hummingbot Sep 29, 2026
0c9d40c
Say a branch was never pushed, rather than blaming the network
david-hummingbot Sep 29, 2026
057c5d2
Recognise a local image build on the classic image store too
david-hummingbot Sep 29, 2026
02dff12
Put the checkout back when stashed work cannot be replayed
david-hummingbot Sep 29, 2026
ba5f875
Prove the server fan-out by rendezvous, not by the clock
david-hummingbot Sep 29, 2026
181728d
Check that the id the dashboard signs in as can administer
david-hummingbot Sep 29, 2026
c16f886
Say why an admin-only settings tab is not there
david-hummingbot Sep 29, 2026
4f9f98a
Print the role as config.yml spells it, not as Python repr
david-hummingbot Sep 29, 2026
1011d7e
Say when a finished update run happened
david-hummingbot Sep 29, 2026
687e29c
Bound the Chrome download so an install cannot hang on it
david-hummingbot Sep 29, 2026
6bafe52
Keep the slashes in the remote's default branch name
david-hummingbot Sep 29, 2026
5995017
Refuse to recreate containers that belong to another checkout
david-hummingbot Sep 29, 2026
7a8f067
Say Docker is down, rather than blaming the image
david-hummingbot Sep 29, 2026
20e643d
Fix seven defects found in review
david-hummingbot Sep 29, 2026
3a7ca12
Fix three defects in the previous round's fixes
david-hummingbot Sep 29, 2026
217762f
Fix three defects found reviewing this branch's own fixes
david-hummingbot Sep 29, 2026
5d11590
Make a chat-authored routine runnable, not merely listed
david-hummingbot Sep 30, 2026
efc21ab
Reload every open tab, and only once the successor is answering
david-hummingbot Sep 30, 2026
0ce7e6d
Take the shared reload hook rather than keeping a second one
david-hummingbot Sep 30, 2026
67443a8
Fix seven findings from review
david-hummingbot Oct 1, 2026
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
29 changes: 29 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# Line endings are part of a file's bytes, and Condor digests some of those
# bytes: `content_digest` in condor/layering.py stamps a forked agent file with
# a hash of the stock file it came from, and the staleness check compares that
# stamp against the stock file later. Leaving endings to each host's git config
# makes that hash platform-dependent.
#
# `text=auto` normalizes to LF in the repository. The explicit `eol=lf` entries
# are the files whose bytes are either hashed or executed, where a CRLF working
# tree would be actively wrong rather than merely inconsistent.
* text=auto

# Agent content: hashed by content_digest, so the working tree must match what
# was committed byte for byte on every platform.
*.md text eol=lf
*.yml text eol=lf
*.yaml text eol=lf

# Executed by a POSIX shell; a CRLF copy fails with a bad-interpreter error.
*.sh text eol=lf
Makefile text eol=lf

# Binary, so git must not touch them.
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.ico binary
*.woff binary
*.woff2 binary
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -226,3 +226,10 @@ frontend/.claude/
# one's commit — which is how 1529d04 came to carry a message describing work it does not
# contain. Kept on disk, out of the index.
GATEWAY_ISSUES.md

# The dashboard is built into dist.new and swapped into place, keeping dist.old
# as a rollback (see build_frontend). `dist/` above does not match either name,
# and an unignored scratch directory would make every mid-build tree dirty --
# which is itself something repo_blocks() can refuse an update over.
frontend/dist.new/
frontend/dist.old/
1 change: 1 addition & 0 deletions .python-version
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
3.12
21 changes: 18 additions & 3 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -53,10 +53,14 @@ install: setup
@$(MAKE) setup-chrome
@-$(MAKE) doctor

# Bounded, and says which way it failed. The bare `python -c` this replaces had
# no timeout and sent stderr to /dev/null, so a stalled download sat on
# "Setting up Chrome..." for ever with nothing to read and no way to finish the
# install. See condor/setup_chrome.py; it always exits 0, because charts are
# optional and an optional renderer must not fail an install.
setup-chrome:
@echo "Setting up Chrome for chart rendering..."
@uv run python -c "import kaleido; kaleido.get_chrome_sync()" 2>/dev/null || \
echo "Chrome setup skipped (not required for basic usage)"
@uv run python -m condor.setup_chrome || true

doctor:
@bash -c ' \
Expand All @@ -65,6 +69,13 @@ doctor:
uv run python -m condor.doctor \
'

# Builds into dist.new and swaps, exactly as the in-process updater does
# (utils/updater.build_frontend). vite's `emptyOutDir` would otherwise empty the
# directory in place: `run` and `restart` stop Condor first, but this target is
# public and a Condor started any other way -- `run-fg`, a supervisor, a second
# checkout -- is left serving an empty dist for the length of the build, and a
# build that then fails leaves it with no dashboard at all.
#
# Reinstall when the lockfile moved, not merely when node_modules is absent:
# after the first boot the directory always exists, so a pull that adds a
# dependency would otherwise build against a stale tree and fail. npm rewrites
Expand All @@ -79,7 +90,11 @@ build-frontend:
[ package-lock.json -nt node_modules/.package-lock.json ]; then \
npm ci || exit 1; \
fi; \
npm run build \
rm -rf dist.new && \
npm run build -- --outDir dist.new --emptyOutDir && \
rm -rf dist.old && \
{ [ -d dist ] && mv dist dist.old || true; } && \
mv dist.new dist \
Comment on lines +93 to +97

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Concurrent builds can remove bundle

If make build-frontend runs while the in-process updater is building, both processes delete and reuse the same dist.new and dist.old directories. The Makefile target does not take the update lock, so one build can remove the other's output or interleave the renames, leaving the running dashboard without a complete bundle. The in-process build uses the same directories in utils/updater.py.

'

# Fails early (before the frontend build) if Condor is already up
Expand Down
96 changes: 94 additions & 2 deletions condor/doctor.py
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,81 @@ def check_dependencies() -> list[Check]:
_PLACEHOLDER_CREDENTIALS = {"admin", "password", "changeme", ""}


def _check_admin_can_administer(data: dict | None) -> list[Check]:
"""Whether ``ADMIN_USER_ID`` is an id ``config.yml`` treats as an admin.

Two files have to agree about one number and nothing checked that they
did. ``.env`` decides who the dashboard *is* -- local mode logs in as
``ADMIN_USER_ID`` with no password -- while ``config.yml`` decides what
that id may *do*. Disagree and every admin route answers 403: the Updates
tab is not rendered, the "Updates available" notification links to a tab
that is not there, and clicking it lands on Servers with nothing said.

Nothing was wrong enough to notice. The old check asked only whether the
variable was set, so an install in exactly this state reported
``ADMIN_USER_ID`` green on the very value that had no rights.

A duplicated key is worth naming on its own: ``.env`` is read by three
parsers and the last assignment wins in all of them, so a second line is
silently authoritative and the first is the one people read.
"""
from utils.config import ADMIN_USER_ID, LOCAL_MODE

admin_id = str(ADMIN_USER_ID or "").strip()
if not admin_id or not isinstance(data, dict):
return []

duplicates: list[Check] = []
if ENV_PATH.exists():
try:
assignments = [
line
for line in ENV_PATH.read_text(encoding="utf-8").splitlines()
if re.match(r"^\s*(export\s+)?ADMIN_USER_ID\s*=", line)
]
except OSError:
assignments = []
if len(assignments) > 1:
duplicates.append(
Check(
"ADMIN_USER_ID (duplicate)",
FAIL,
f"{len(assignments)} assignments in .env — the last one wins "
f"({admin_id}), the others are dead. Delete all but one.",
)
)

users = data.get("users") or {}
role = ""
for key, record in users.items():
if str(key) == admin_id and isinstance(record, dict):
role = str(record.get("role") or "")
break

if role.lower() == "admin":
return duplicates

if not role:
detail = (
f"config.yml has no user {admin_id}, so the dashboard logs in as an "
"id with no role at all and every admin route answers 403."
)
else:
detail = (
f"config.yml gives user {admin_id} the role '{role}', not 'admin', "
"so every admin route answers 403 — no Updates tab, and the "
"'Updates available' notice links to a tab that is not rendered."
)
where = "the dashboard signs in as this id" if LOCAL_MODE else "this id"
return duplicates + [
Check(
"Admin access",
FAIL,
f"{detail} Set `users.{admin_id}.role: admin` in config.yml " f"({where}).",
)
]


def _check_placeholder_credentials(data: object) -> list[Check]:
if not isinstance(data, dict):
return []
Expand Down Expand Up @@ -262,6 +337,7 @@ def check_config() -> list[Check]:

data = yaml.safe_load(config_yml.read_text(encoding="utf-8"))
checks.append(Check("config.yml", OK, "present and parses"))
checks.extend(_check_admin_can_administer(data))
checks.extend(_check_placeholder_credentials(data))
except Exception as e:
checks.append(Check("config.yml", FAIL, f"failed to parse: {e}"))
Expand Down Expand Up @@ -317,8 +393,17 @@ def _listening_binds(port: int) -> list[str]:
binds = []
for line in proc.stdout.splitlines()[1:]: # skip header row
cols = line.split()
# ``-sTCP:LISTEN`` makes lsof print the state as its own
# trailing token, so NAME is ``TCP 127.0.0.1:8088 (LISTEN)``
# and the last column is ``(LISTEN)`` -- not an address at all.
# Taking it and splitting on "(" yielded the empty string,
# which ``_is_public_bind`` used to read as a wildcard: every
# loopback listener on a host without ``ss`` reported as bound
# to all interfaces.
if cols and cols[-1].startswith("("):
cols = cols[:-1]
if cols:
binds.append(cols[-1].split("(")[0])
binds.append(cols[-1])
return binds
except Exception:
pass
Expand Down Expand Up @@ -357,8 +442,15 @@ def _bind_host(addr: str) -> str:


def _is_public_bind(addr: str) -> bool:
"""Whether ``addr`` is a wildcard bind — reachable from off this machine.

The empty string is deliberately *not* a wildcard. It is what a bind this
code failed to parse looks like, and answering True for it turns a parsing
bug into a security warning about a port that may well be on loopback.
Unknown is not public; a bind nobody could read is reported by nothing.
"""
host = _bind_host(addr)
return host in ("0.0.0.0", "*", "::", "[::]", "")
return host in ("0.0.0.0", "*", "::", "[::]")


def check_dashboard_port() -> list[Check]:
Expand Down
Loading
Loading