Retesting a finding creates an independent conversation branch that
snapshots the source history and model trace, so verification can run
multi-turn without writing to the original session.
- POST /api/vulnerabilities/:id/retest creates the branch and persists the
prompt, so a lost response, navigation or refresh can resume it
- new update_vulnerability_status tool closes the loop; a retest branch may
only read and update its own finding
- {{vulnerability_id}}, {{conversation_id}} and {{source_conversation_id}}
resolve on send, in the first turn and in follow-ups
- evidence appends to a read-only vulnerabilities.retest_log column instead
of retest_notes: that field is operator-authored and the edit form
rewrites it wholesale, so a routine edit used to erase the retest trail
- the retest prompt is prefilled with the operator's retest_notes, which
already states how to verify the fix
- copied messages are re-bound as time.Time. Scanning a DATETIME column into
a string yields RFC3339Nano while AddMessage writes the driver's
space-separated format, and messages sort on the raw created_at text, so a
branch rendered its new turn above the inherited history
Inserting `version` above a leading `---` put it in a document of its own, so
every setting after the marker parsed as a second document and was silently
ignored: the installation came up on defaults with a config that looked intact.
Both writers - the Go one and upgrade.sh's - now insert after the document
start, and the parse-back check still refuses anything a line rewrite cannot do
safely.
upgrade.sh rewrote config.yaml through a temp file created with the process
umask, so a 0600 config came back 0644 on every package upgrade - the file
holding the API keys became group/world readable. It now creates the temp file
privately and restores the original mode before the atomic replace.
A package upgrade also deleted `.update-build-pending`, because rsync --delete
only keeps what the exclude list names: the installation forgot a compile it
still owed and the next click reported "already up to date". Every one-click
update artifact is on the keep list now, asserted against .gitignore so the
next one cannot be forgotten the same way.
Three ways the installation could still end up with its source, its binary and
the operator's content describing different things.
Rollback copied aside only *tracked* protected files, so a role upstream had
deleted and the operator had recreated at the same path was untracked - and
`git reset --hard` wrote the old commit's copy over it. The stash now also
covers untracked protected paths the target commit writes to, and names them in
keptContent like any other file it saved.
The pending-build marker said only "a compile is owed". An update that found
new commits therefore merged them and built the *new* tree, leaving `.prev`
holding a binary for a commit the state file no longer named - a rollback then
paired old source with the original binary. And a marker left behind after a
successful swap rebuilt and overwrote that same `.prev`. The marker now names
its target commit and the hashes on both sides of the swap, the state records
which commit the live binary came from, an owed build is finished before the
tree moves again, and a retry that finds the swap already done clears the
marker instead of repeating it. Rollback verifies that pairing before touching
anything and refuses (binary_changed) rather than reset the source beside a
binary it cannot account for; an update whose build failed never swapped one,
so its rollback moves the source only and no longer demands a `.prev`.
The version pair is recorded before the config is written, so a crash in
between still leaves a rollback that can put the old number back.
The four retry scenarios are driven through a scripted `go` (a POSIX shell script
placed first on PATH) and a PATH with a symlinked git. On Windows neither
harness resolves: the file would not be found through LookPath and would not
execute, and the symlink needs privileges - the tests would either fail or
silently assert nothing. They now skip there with the reason named, instead of
reporting a platform they cannot speak for. `GOOS=windows go vet ./internal/update`
compiles clean; macOS runs them for real.
FileVersion reads the live config with the YAML parser, which understands
`"version":` and `'version':` exactly like the bare key. WriteVersion matched
lines by the literal prefix `version`, so a legal quoted-key config was not
recognized as already carrying the field at all: instead of replacing the line,
the writer prepended a second `version:` key. The file then failed to load
("mapping key \"version\" already defined"), i.e. the version sync could leave
an installation that no longer starts - while reporting success through the
real entry point (before=v1.0.0 -> after=v1.7.22, err=nil).
The writer now matches all three spellings, top-level only, first occurrence
only, and before writing it re-reads the new text with the same YAML parser: if
the result does not parse, or the top-level version does not read back as the
target, the write is refused and the original file is left byte-for-byte alone
(the update reports the skip and stands - a stale version number is a display
problem, a config that does not load is an installation that does not start).
Forms a line-based writer cannot rewrite, like JSON one-liners and block
scalars, are refused rather than corrupted.
The tarball path's writer in upgrade.sh gets the same rule set: top-level
three-spelling matching, first match only, a refusal for block/multi-line
scalars and for duplicate keys, plus the value validation the Go side applies -
a tag with a quote or newline in it can no longer reach the live config.
Regression tests: the config package covers the three quoted spellings and the
three refused forms (JSON one-liner, block scalar, duplicate key - file must
stay untouched), and the update package drives a quoted-key live config through
a real Apply and Rollback; the latter fails on the pre-fix tree with the
duplicate key it used to produce.
`./run.sh --reset-admin-password` rebuilds the binary whenever it is missing or
older than the sources, through build_go_project_quiet - the one build path left
that compiled the single file `cmd/server/main.go`. A file-based go build only
compiles the file it names, so it dropped every sibling of package main that
this PR added; measured on the parent commit:
cmd/server/main.go:35:22: undefined: updateCommandIfNeeded
(the symbol lives in cmd/server/update_cli.go), which made the documented
password-reset entry exactly the path that could not build itself. The normal
start path and upgrade.sh already build `./cmd/server`; the quiet path now does
too, and a test scans run.sh, upgrade.sh and scripts/*.sh so a file-based
go build cannot come back unnoticed - it fails on the pre-fix tree with the
command named.
The review found two failure paths that reported success for work the engine had
not done.
Rollback: it refused only when *non-protected* source files were modified, and
then ran `git reset --hard` to the previous commit - which discards every local
change to tracked files, not just the update's. Edits to roles/skills/tools (the
update itself keeps those via its own stash) and edits made after the update
were silently wiped, while Result said nothing: deployment.md's promise that
"work done afterwards is not swept away" did not hold. Rollback now copies every
protected file that differs right now into `.update-backup/rollback_<ts>/`
before the reset, sets the copies back after it, re-applies hand deletions, and
names what it kept in Result.KeptContent like an update does.
Retry: Apply merges and writes the state file before it builds, so a failed (or
toolchain-less) build leaves HEAD on the new commit with the old binary still in
place. The retry then took the `!snap.UpdateAvailable` no-op branch, aligned the
version and returned err=nil - the job was recorded as succeeded, the page said
"update finished", the CLI printed that source and binary were in place, and the
old executable kept running. The tree now carries a `.update-build-pending`
marker, written the moment the merge lands (and when an adoption commits to its
work tree) and cleared only after the new binary is in place. The no-op branch
reads it: with a toolchain it compiles and reports the build (commits=0,
binaryBuilt=true); without one it keeps refusing exactly like the first attempt,
so "install go and click again" is now a real instruction. The console stops
treating "nothing to pull" as "nothing to do" (the retry button stays live, the
confirm dialog and the done toast say the build is what will happen), and the
CLI's printResult no longer swallows the build-only result.
Both paths are covered by regression tests that fail on the pre-fix tree: two
rollback scenarios driven through real git (edit before and after the update, a
hand deletion), and four retry scenarios driven through a scripted go on PATH
(failed build, state-write failure, an adoption's failed build retried by the
next update, and no toolchain then toolchain).
Reported from the live installation: after opening 系统设置 → 一键更新 (the
automatic check runs) the 保存更新源 button silently stopped working — a click
produced no request at all and the server log never saw a POST.
Root cause: the busy flag is a render input — it stamps `disabled` +
`data-state-disabled` on the action buttons — but the console only ever
re-rendered *while* busy. checkForUpdates (including the automatic one) renders
on entry and again when the response lands, and withUpdateBusy never rendered
once the flag cleared, so the last render left the source-save button disabled
for good. The carrier makes it permanent: data-state-disabled tells the
platform's permission gate not to re-enable the element either. Measured in a
browser after the auto-check: the DOM carried `disabled
data-state-disabled="true"` while updateBusy was already false.
Fix: withUpdateBusy now renders symmetrically — once on entry (the form visibly
locks while an action is in flight) and once after the flag clears (usable
again, button re-enabled) — skipping the exit render only when the restart
watchdog owns the view. The transient error banner stops being an injected div
and becomes state (updateActionError) rendered by renderUpdateConsole, so the
exit render cannot wipe it; it clears when a new action starts or the console is
re-entered.
Tests: the regression follows the operator's exact chain — deferred automatic
check → the form must be enabled again → clear the address → save must reach the
server — plus a second-save check; the first fails on the old code on the
disabled markup. Verified end to end in a real browser against a scratch
installation: the render trace reads entry(busy)→response(busy)→exit(idle), and
both saves (set and clear) hit the API and land in config.yaml.
The repository's config.example.yaml ships the release version this code was cut
from (upstream bumps it in the release commit: v1.7.21's config.example says
v1.7.21), while the live config.yaml is operator data that no merge ever touches.
So after an update the header badge - and the ?v= on every static asset - kept
saying the version the installation was born with while the tree already ran
newer code.
An update, an adoption, or a rollback now keeps the two together: after the merge
the update reads the version the new code carries (config.example.yaml) and writes
it into the live config's version field - that one line, temp file plus atomic
rename, everything else byte-for-byte preserved, and only when the value actually
differs. The job log records the change (the page shows the step), and the state
file records both numbers so a rollback puts the old version back with the old
code; a number hand-edited after the update is the operator's and is left alone.
The side effect worth naming: the version bump also changes the ?v= query on
static assets, so a browser stops serving the pre-update JavaScript.
Trees without a live config or without a shipped version are left untouched -
a source checkout has nothing to sync, and nothing is ever guessed.
internal/config gains the two focused helpers (FileVersion / WriteVersion, line
level like the tarball upgrade path always did); tests pin the replace, the
insert-when-absent, byte preservation, the mode, the malformed-value refusal, and
the update/adopt/rollback/leave-alone behaviours end to end.
The update source used to be three things - a remote name or an address, plus an
optional branch - and unset it followed whatever this directory's remotes and
upstream happened to say. That asked people to understand git topology before
they could update, and the person it was built for could not tell what "follow
the remote this tree tracks" meant. It is now one field: config.yaml update.repo,
an address, empty meaning the official repository
(https://github.com/AIPentest/CyberStrikeAI.git). Point it at a mirror (GitHub is
hard to reach in some places) or at your own fork and that is the whole
configuration.
The branch nothing is any more: an update follows the source repository's own
default branch, resolved from the source itself (ls-remote --symref HEAD), so a
fork whose default is not "main" still works and nobody has to name a branch.
Remote names, the mine/origin/upstream ranking, @{upstream} tracking and the
adopt flow's branch argument are all gone with it.
The console page loses two input fields: the source section is one address, a
hint that empty means the official repository, and a save button; clearing the
address is the way back to the default. The install facts drop the remote name,
the check section shows the source's default branch once the network answer
arrives, and the adopt entry no longer requires saving an address first - the
official default makes connecting a tarball install a zero-configuration action.
The CLI now reads the same update.repo field the page saves (LoadUpdateRepo reads
just that field, without validating the rest of the file - a broken unrelated
section must not block the path that fixes the tree), so the keyboard and the
button cannot update from different repositories. -check-update prints the source
it is about to use.
Tests: the source suite now pins the default (official), the single-address save
(including the empty-string reset), and that the followed branch comes from the
source's own default branch rather than a hardcoded "main"; the console suite
posts the one field and renders the default hint; docs, config.example.yaml and
upgrade.sh describe the one-field story.