# Developer Guide [中文](../zh-CN/developer-guide.md) This guide is for contributors extending CyberStrikeAI. The project is a Go single-service application with a static frontend, SQLite persistence, Agent/MCP orchestration, and optional high-risk security subsystems. ## Project Layout ```text cmd/server/ service entrypoint internal/app/ app wiring, routes, MCP tool registration internal/handler/ HTTP handlers internal/database/ SQLite access internal/security/ auth, rate limits, shell execution internal/mcp/ MCP server and external MCP manager internal/multiagent/ Eino single-agent, multi-agent, middleware internal/workflow/ graph orchestration runtime internal/knowledge/ indexing and retrieval internal/c2/ built-in C2 internal/project/ project fact blackboard web/static/ frontend JS/CSS/assets web/templates/ HTML templates tools/ YAML command tools roles/ role YAML agents/ multi-agent Markdown definitions skills/ Agent Skills docs/ documentation ``` ## Development Startup ```bash go run ./cmd/server --config config.yaml ``` The frontend is static. Most JS/CSS/template changes only require a browser refresh. ## One-Click Update: Letting an Installation Update Its Own Source On a deployed machine the routine is "my tree moved forward by a few dozen commits -> bring this installation up -> rebuild -> swap the binary -> restart". This used to be only `upgrade.sh`, which pointed at one fixed upstream repository, so for anyone running a fork "upgrade" meant "overwrite yourself with somebody else's code". The capability now lives in the platform (`internal/update`) and the console page, the REST API and the CLI share one implementation, so the three surfaces cannot disagree about why an update was refused. ### Three entry points | entry | how it is used | |---|---| | console | System settings -> One-click update (`#system-update`, the old deep link still works). Opening this section reads the local tree only and does not go online; **Check** is what performs the fetch; **Update** starts a job the page polls; the "exit after updating" tick is pre-ticked when a supervisor is detected (launchd/systemd startup markers), and while the process is down the page watches for its return and reloads itself (sessions live in memory, so a fresh login follows); a rollback button sits below | | REST | `GET /api/system/update` (state on disk, no network), `POST /api/system/update/check` (fetch, then report the gap), `POST /api/system/update/apply` (`202` with `job_id`, polled through `GET /api/system/update/job`), `POST /api/system/update/rollback` | | CLI | `./cyberstrike-ai -check-update`, `./cyberstrike-ai -update`, `./cyberstrike-ai -update-rollback`. The install root is the directory holding `--config`, or the current directory when `--config` was not given | Permissions are `update:read` (GET) and `update:apply` (the four mutating endpoints). `update:apply` additionally requires a global (`all` scope) session: one machine has one source tree, and an `assigned`/`own` session must not be able to move the code everybody else is running. ### Where the code comes from From **the update source repository**: one address, the official repository `https://github.com/AIPentest/CyberStrikeAI.git` by default, and the update follows that repository's **own default branch** (nothing about branches is configured, and "main" is not hardcoded), so an installation with no configuration at all still has exactly one answer to "where does an update come from". `upgrade.sh` is now a thin shell over the same implementation: when this directory is a git work tree it just calls `./cyberstrike-ai -update`. ### Changing the source (the update section of config.yaml) The update source is a single field: a repository address. Empty means the official repository; to follow your own fork, a mirror (when GitHub is hard to reach), or somebody else's repository, this is the one place to change: ```yaml update: repo: https://github.com/AIPentest/CyberStrikeAI.git # one repository address; empty/absent = the official repository ``` The console's "Update source" block edits and saves this address (saving it empty goes back to the official repository). Saving validates the address against an allowlist: https/http/ssh/git/file plus local absolute paths - git's `ext::` transport executes commands and is rejected outright. ### Connecting a non-git installation (the tarball kind) An installation that started as an unpacked Release archive has no `.git`; it can be connected from the same page (the default official repository works without any configuration): the preview fetches in a throwaway repository and first lists **the local files the target would replace** and **the operator content that will be kept**; on confirm the directory becomes a git work tree (`git init`, origin added, the target branch checked out), every replaced file is kept under `.update-backup//overwritten/`, operator content is stashed and restored as usual, and the binary is rebuilt. From then on it is a normal installation with one-click update and rollback - but note there is no git history before the connection, so the connection itself has no earlier commit to roll back to; the first real update writes the first rollback point. ### The version number follows the code The repository's `config.example.yaml` carries the release version this code was cut from (upstream bumps it in the release commit), while your `config.yaml` is operator data that no update ever touches. So after a successful merge the update writes the new code's version back into `config.yaml`'s `version` field (that one line only, temp file plus atomic rename; the job log records the old and the new number). After a restart the header badge and the static assets' `?v=` both match the code again - which is also why the frontend cache invalidates itself on upgrade. A rollback puts the number back too (unless somebody hand-edited it in the meantime), and connecting a tarball install syncs it as well. ### The four refusals | situation | reason | meaning and what to do | |---|---|---| | product **source** modified locally (a path outside the protected list) | `local_source_edits` | An update is a download, not a conflict decision. The file names are listed; commit or restore them first | | branch **diverged** from its remote (ahead and behind at once) | `diverged` | That is a merge decision; the counts are reported so you can resolve it by hand | | no Go toolchain on this box | `no_toolchain` | **The source still updates**; only the binary is not swapped. Install `go` and press update again to finish | | this directory is not a git work tree | `not_a_repo` (`installed: false` in the status) | A tarball installation has no remote to fast-forward; update it through the release package | `apply` is asynchronous, so it always answers `202` first and the refusals above arrive in the polled `job.failure` (`reason` / `message` / `items`). `rollback` is synchronous and maps refusals to status codes (`local_source_edits`, `diverged`, `no_state`, `moved_since_update`, `no_binary` -> 409, anything else -> 400). Other failures (`merge_failed`, `build_failed`, `swap_failed`) carry git's or the compiler's own output; a failed build leaves the previous binary in place while the source has already moved and the state file is written, so it stays rollback-able. Check, update, adoption, rollback and restart share an operating-system file lock in the install directory (`.update-lock`). Wait for the other operation when `update_busy` is reported. The system releases the lock when its process exits; do not delete the lock file to bypass it. ### How operator content survives `roles/ skills/ tools/ agents/ knowledge_base/ data/ log/ venv/ config.yaml .env` belong to the **operator** (`update.Protected` is the judge). Before the merge, only those of those paths the update actually writes to are copied into `.update-backup//` and removed from the tree - your extra directories that upstream never touched are not disturbed - and they are put back afterwards, which is what "update the code, keep my work" means. The result names every file in `keptContent` instead of quietly dropping it: where both you and upstream changed the same file, the version that lands is yours, and the result says so. ### Rollback `./cyberstrike-ai -update-rollback`, or the button on the page: `git reset --hard` back to the commit the update came from, the binary kept as `cyberstrike-ai.prev` is put back when that update swapped one, and `.update-state.json` is removed. An update whose build failed never swapped a binary, so its rollback moves the source only - the binary on disk already belongs to the commit it returns to. It refuses when there is no update record (`no_state`), a swap is owed but no `cyberstrike-ai.prev` is left (`no_binary`), a recorded commit is not in this repository (`bad_state`), the binary on disk is not the one the record pairs with that commit (`binary_changed`), **HEAD has moved since that update** (`moved_since_update` - a rollback undoes the update, not the work done after it), or source is modified locally (`local_source_edits`). Rollback writes `.update-rollback-pending.json` before changing source or binaries. Run rollback again after an interruption to finish recovery; the console reports `rollbackPending` and pauses new updates. Keep the recovery record, backup directory and `.prev` file. If recorded binary hashes do not match the files on disk, recovery refuses to overwrite them. ### The two restart cases The process only stands down (graceful `Shutdown`, then exit 0) when the request explicitly says `restart: true` **and** a restart hook was wired at startup. Whether it actually comes back is up to a supervisor (systemd, `run.sh`), and the page says exactly that rather than promising a boot that may not happen. Asking for a restart with no hook wired is a `400`, instead of stopping the service and claiming it restarted. **A pending binary is a durable state, and it can be activated later.** The status endpoint compares the binary identity captured at startup (size + nanosecond mtime) with what is on disk now: after an update, a rollback or a CLI run swapped the file without a restart, `needsRestart` is true, `binaryBuiltAt` says when it was built, and the console grows a persistent banner with **Restart now** (`POST /api/system/update/restart`; `409 nothing_pending` when there is nothing to activate, `409` while a job runs, `400` without a hook). The `supervised` field comes from environment markers (launchd's `XPC_SERVICE_NAME`, systemd's `INVOCATION_ID`/`JOURNAL_STREAM`) and only decides the tick's default. **And the page recovers by itself.** While the process is down the console becomes a recovery view that probes the status endpoint every 2 seconds: a 200 means the old process is still answering (keep waiting), a dead connection means it is on its way out, and a 401 from a process that no longer knows the session is the proof the restart happened - the page then `location.replace`s itself back to `#system-update`. Leaving the console stops the watching instead of yanking the user back. ## Adding a Business Module Do not add only a handler. A complete module usually needs: 1. Data model and SQLite migration. 2. Handler: parameters, errors, pagination/filtering. 3. Audit: management actions. 4. Monitor: long-running execution state. 5. MCP: whether Agents should call it. 6. HITL: approval boundary for MCP tools. 7. OpenAPI: update `/api/openapi/spec`. 8. Frontend: i18n, states, empty/error UI. 9. Tests: DB, handler, edge cases. 10. Docs: config, usage, troubleshooting, safety impact. Missing one of these usually becomes a later usability or safety bug. ## Error Response Design Prefer stable JSON: ```json { "error": "machine_readable_code", "message": "human-readable explanation" } ``` Frontend needs stable fields, users need actionable messages, and logs need detailed internal errors. ## Long-Running Tasks For scanning, indexing, batch tasks, C2, or external operations, answer: - Can it be cancelled? - Can progress be queried? - Can it be retried? - Where is the result stored? - Does state survive page refresh? - Does it block the HTTP request? If not, use task tables, event streams, or monitoring. ## Extending Tools Prefer `tools/*.yaml` for command tools. Use Go built-in tools when the tool needs internal state or structured integration. Built-in tools should define clear input schemas, handle timeouts and errors, and respect HITL for risky actions. ## Frontend Changes Use existing helpers such as `apiFetch`, modal utilities, notifications, and i18n. Update both `web/static/i18n/zh-CN.json` and `web/static/i18n/en-US.json` for new visible text. Avoid putting secrets or provider keys in frontend code. ## Test Priority High-value tests: - config hot-apply; - HITL branches; - shell timeout/no-output; - external MCP recovery; - KB indexing and post-processing; - WebShell OS/encoding detection; - SQLite migration compatibility. ## Source Anchors - App wiring: `internal/app/app.go` - Config apply: `internal/handler/config.go` - OpenAPI: `internal/handler/openapi.go` - Tool executor: `internal/security/executor.go` - Skill package: `internal/skillpackage/` - One-click update: `internal/update/` (status, apply, rollback), HTTP surface in `internal/handler/update.go`, CLI switches in `cmd/server/update_cli.go`, thin shell in `upgrade.sh`