0.29.1 — review-fix round on E3: RunOutput, sshKeyPath fallback, RunStreaming consolidation, signer cache, stderr in errors
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
ci / web (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled

This commit is contained in:
2026-08-08 23:01:10 +02:00
parent 75c0848a6f
commit 7236c46e5c
8 changed files with 88 additions and 249 deletions

View File

@@ -1 +1 @@
0.29.0 0.29.1

View File

@@ -24,11 +24,6 @@ func main() {
fmt.Fprintln(os.Stderr, "usage: nomos serve") fmt.Fprintln(os.Stderr, "usage: nomos serve")
os.Exit(1) os.Exit(1)
} }
// Fast-path the Docker healthcheck BEFORE any Infisical/secrets init. The
// nomos runtime image is distroless (no shell/wget), so the container
// probes itself via `nomos healthcheck`. Secrets resolution retries
// Infisical ~4x per key when it's down (~30s), which would blow the 5s
// healthcheck timeout — so this must run first and stay trivial.
if os.Args[1] == "healthcheck" { if os.Args[1] == "healthcheck" {
runHealthcheck() runHealthcheck()
return return
@@ -37,9 +32,6 @@ func main() {
if mcpURL == "" { if mcpURL == "" {
mcpURL = "http://localhost:8090/mcp" mcpURL = "http://localhost:8090/mcp"
} }
// api's combinedAuth requires a bearer token on every request (no
// dev-open bypass — plans/2026-07-12-wails-desktop-app.md 0.4); this is
// the same shared secret api validates against (OIKOS_MCP_BEARER_TOKEN).
mcpToken := os.Getenv("OIKOS_MCP_BEARER_TOKEN") mcpToken := os.Getenv("OIKOS_MCP_BEARER_TOKEN")
agentSlug := os.Getenv("NOMOS_AGENT_SLUG") agentSlug := os.Getenv("NOMOS_AGENT_SLUG")
@@ -52,10 +44,6 @@ func main() {
databaseURL = os.Getenv("OIKOS_DATABASE_URL") databaseURL = os.Getenv("OIKOS_DATABASE_URL")
} }
// Resolve secrets from Infisical, falling back to env vars.
// The MCP token and OpenRouter key are fetched once at startup and
// injected via os.Setenv so downstream code (newAgent) picks them up
// without signature changes.
sec := secrets.NewManagerFromConfig( sec := secrets.NewManagerFromConfig(
os.Getenv("OIKOS_INFISICAL_SITE_URL"), os.Getenv("OIKOS_INFISICAL_SITE_URL"),
os.Getenv("OIKOS_INFISICAL_CLIENT_ID"), os.Getenv("OIKOS_INFISICAL_CLIENT_ID"),
@@ -188,8 +176,8 @@ func main() {
<-ctx.Done() <-ctx.Done()
slog.Info("nomos: shutting down") slog.Info("nomos: shutting down")
srv.Shutdown(context.Background()) srv.Shutdown(context.Background())
clientPool.closeAll() clientPool.closeAll()
default: default:
fmt.Fprintf(os.Stderr, "unknown command: %s\n", os.Args[1]) fmt.Fprintf(os.Stderr, "unknown command: %s\n", os.Args[1])
@@ -197,9 +185,6 @@ func main() {
} }
} }
// runHealthcheck self-probes NOMOS_LISTEN/healthz and exits 0 on HTTP 200,
// 1 otherwise. Used by the Docker healthcheck (the distroless runtime image
// has no wget/shell). Must stay fast — call it before any secrets init.
func runHealthcheck() { func runHealthcheck() {
addr := os.Getenv("NOMOS_LISTEN") addr := os.Getenv("NOMOS_LISTEN")
if addr == "" { if addr == "" {

View File

@@ -1,7 +1,6 @@
package httpapi package httpapi
import ( import (
"bytes"
"context" "context"
"encoding/base64" "encoding/base64"
"encoding/json" "encoding/json"
@@ -10,7 +9,6 @@ import (
"os" "os"
"strconv" "strconv"
"strings" "strings"
"sync"
"time" "time"
"github.com/dtoro/oikos/internal/actuator" "github.com/dtoro/oikos/internal/actuator"
@@ -77,24 +75,8 @@ const sshExecTimeout = 10 * time.Minute
// streamWriter buffers everything it is given while forwarding each write to a // streamWriter buffers everything it is given while forwarding each write to a
// sink. One on session.Stdout and another sharing the same buffer on // sink. One on session.Stdout and another sharing the same buffer on
// session.Stderr reproduces CombinedOutput's interleaving in the order the // session.Stderr reproduces CombinedOutput's interleaving in the order the
// remote end produced it. Mirrors the twin in internal/mcp/server.go. // remote end produced it. Shared implementation lives in internal/actuator
type streamWriter struct { // (actuator.streamWriter / actuator.RunStreaming).
mu *sync.Mutex
buf *bytes.Buffer
stream string
sink execlog.Sink
}
func (w *streamWriter) Write(p []byte) (int, error) {
w.mu.Lock()
w.buf.Write(p)
w.mu.Unlock()
if w.sink != nil {
// Copy: the ssh library reuses p once Write returns.
w.sink(w.stream, append([]byte(nil), p...))
}
return len(p), nil
}
func sshExec(ctx context.Context, host, user, command string) (string, error) { func sshExec(ctx context.Context, host, user, command string) (string, error) {
return sshExecStream(ctx, host, user, command, nil) return sshExecStream(ctx, host, user, command, nil)
@@ -122,69 +104,7 @@ func sshExecStream(ctx context.Context, host, user, command string, sink execlog
} }
defer client.Close() defer client.Close()
session, err := client.NewSession() return actuator.RunStreaming(ctx, client, command, sink, sshExecTimeout)
if err != nil {
return "", fmt.Errorf("session: %w", err)
}
defer session.Close()
var (
mu sync.Mutex
buf bytes.Buffer
)
session.Stdout = &streamWriter{mu: &mu, buf: &buf, stream: "stdout", sink: sink}
session.Stderr = &streamWriter{mu: &mu, buf: &buf, stream: "stderr", sink: sink}
collected := func() string {
mu.Lock()
defer mu.Unlock()
return strings.TrimSpace(buf.String())
}
done := make(chan error, 1)
go func() {
// See internal/mcp/server.go's sshExec for why this recovers rather
// than letting a rare SSH-library panic crash the whole api process.
defer func() {
if r := recover(); r != nil {
done <- fmt.Errorf("panic in ssh exec: %v", r)
}
}()
// Run rather than CombinedOutput so the assigned writers are used;
// Run returns only after both streams are fully drained.
done <- session.Run(command)
}()
select {
case err := <-done:
text := collected()
// A non-zero exit MUST surface as an error. The previous guard only
// errored when there was no output, so a `pct create` that printed
// "CT 132 already exists" and exited non-zero was reported as
// success — the execution was marked completed though nothing was
// provisioned.
if err != nil {
if text != "" {
return text, fmt.Errorf("%w: %s", err, text)
}
return text, fmt.Errorf("exec: %w", err)
}
return text, nil
case <-time.After(sshExecTimeout):
// Close the session/client to hang up the remote side; the
// goroutine above will eventually exit once that unblocks Run, but we
// don't wait for it — the caller needs an answer now, not an
// indefinite hang.
session.Close()
client.Close()
// Return what arrived before it hung, rather than "". A provisioning
// command that stalls halfway is precisely when its output matters.
return collected(), fmt.Errorf("timed out after %s waiting for command to finish on %s", sshExecTimeout, host)
case <-ctx.Done():
session.Close()
client.Close()
return collected(), ctx.Err()
}
} }
func resolveHostSSH(ctx context.Context, pool *db.Pool, entitySlug string) (string, string, error) { func resolveHostSSH(ctx context.Context, pool *db.Pool, entitySlug string) (string, string, error) {

View File

@@ -452,30 +452,6 @@ func initSSH() {
// goroutine forever with no way for the caller to ever get an answer. // goroutine forever with no way for the caller to ever get an answer.
const sshExecTimeout = 10 * time.Minute const sshExecTimeout = 10 * time.Minute
// streamWriter buffers everything it is given while forwarding each write to a
// sink. Assigning one to session.Stdout and another (sharing the same buffer)
// to session.Stderr reproduces CombinedOutput's interleaving exactly, in the
// order the remote end actually produced it — which reading from StdoutPipe
// and StderrPipe separately would not guarantee.
type streamWriter struct {
mu *sync.Mutex
buf *bytes.Buffer
stream string
sink execlog.Sink
}
func (w *streamWriter) Write(p []byte) (int, error) {
w.mu.Lock()
w.buf.Write(p)
w.mu.Unlock()
if w.sink != nil {
// Copy: the ssh library reuses p after Write returns, and the sink
// hands the bytes to a DB call that may outlive this frame.
w.sink(w.stream, append([]byte(nil), p...))
}
return len(p), nil
}
func sshExec(ctx context.Context, host, user, command string) (string, error) { func sshExec(ctx context.Context, host, user, command string) (string, error) {
return sshExecStream(ctx, host, user, command, nil) return sshExecStream(ctx, host, user, command, nil)
} }
@@ -502,77 +478,7 @@ func sshExecStream(ctx context.Context, host, user, command string, sink execlog
} }
defer client.Close() defer client.Close()
session, err := client.NewSession() return actuator.RunStreaming(ctx, client, command, sink, sshExecTimeout)
if err != nil {
return "", fmt.Errorf("session: %w", err)
}
defer session.Close()
var (
mu sync.Mutex
buf bytes.Buffer
)
session.Stdout = &streamWriter{mu: &mu, buf: &buf, stream: "stdout", sink: sink}
session.Stderr = &streamWriter{mu: &mu, buf: &buf, stream: "stderr", sink: sink}
// collected returns whatever output has arrived so far. Callable while the
// command is still running, which is what makes partial output on timeout
// possible.
collected := func() string {
mu.Lock()
defer mu.Unlock()
return strings.TrimSpace(buf.String())
}
done := make(chan error, 1)
go func() {
// Recovers a panic in the SSH library internals (rare but not
// impossible) and reports it as a failed command instead of crashing
// the whole api process — every gated action runs through this
// function, so an unrecovered panic here would take down every
// concurrently-running task's execution, not just this one. Without
// this, a panic would ALSO silently degrade to "wait out the full
// timeout" (done never receives, the select below falls through to
// its time.After case) rather than crashing outright — recovering
// and sending an immediate result is strictly better: the caller
// finds out now, not after sshExecTimeout.
defer func() {
if r := recover(); r != nil {
done <- fmt.Errorf("panic in ssh exec: %v", r)
}
}()
// Run rather than CombinedOutput so the assigned writers are used;
// Run returns only after both streams have been fully drained.
done <- session.Run(command)
}()
select {
case err := <-done:
text := collected()
// A non-zero exit MUST surface as an error — matching the fix
// applied to httpapi's sshExec (this copy still had the original
// bug: only erroring when there was no output at all, so a command
// that failed but printed something was silently reported as
// success).
if err != nil {
if text != "" {
return text, fmt.Errorf("%w: %s", err, text)
}
return text, fmt.Errorf("exec: %w", err)
}
return text, nil
case <-time.After(sshExecTimeout):
session.Close()
client.Close()
// Return what the command managed to print before it hung. This used
// to return "", discarding everything — so a hung command, the case
// where the output matters most, was the one case that left no trace.
return collected(), fmt.Errorf("timed out after %s waiting for command to finish on %s", sshExecTimeout, host)
case <-ctx.Done():
session.Close()
client.Close()
return collected(), ctx.Err()
}
} }
// resolveHost resolves a host:<slug> to its reachable IP and SSH user. A thin // resolveHost resolves a host:<slug> to its reachable IP and SSH user. A thin

View File

@@ -43,8 +43,6 @@ func formatCreateResult(slug, entityType string, res checkdefaults.Result) strin
return fmt.Sprintf("Created %s (%s).%s", slug, entityType, formatCheckResult(res)) return fmt.Sprintf("Created %s (%s).%s", slug, entityType, formatCheckResult(res))
} }
// rowsToMap runs a SELECT key, value query and returns the result as a
// map[string]any. Used by get_dashboard_summary to aggregate count queries.
func rowsToMap(ctx context.Context, pool *db.Pool, query string, args ...any) map[string]any { func rowsToMap(ctx context.Context, pool *db.Pool, query string, args ...any) map[string]any {
m := map[string]any{} m := map[string]any{}
rows, err := pool.Query(ctx, query, args...) rows, err := pool.Query(ctx, query, args...)
@@ -62,8 +60,6 @@ func rowsToMap(ctx context.Context, pool *db.Pool, query string, args ...any) ma
return m return m
} }
// queryRowsJSONSingle runs a query and returns the rows as a parsed JSON array
// of maps. Used by get_ontology to embed sub-queries into a structured result.
func queryRowsJSONSingle(ctx context.Context, pool *db.Pool, query string, args ...any) []map[string]any { func queryRowsJSONSingle(ctx context.Context, pool *db.Pool, query string, args ...any) []map[string]any {
rows, err := pool.Query(ctx, query, args...) rows, err := pool.Query(ctx, query, args...)
if err != nil { if err != nil {

View File

@@ -7,11 +7,11 @@ import (
"context" "context"
"crypto/tls" "crypto/tls"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"log/slog" "log/slog"
"net" "net"
"net/http" "net/http"
"os"
"os/exec" "os/exec"
"regexp" "regexp"
"runtime" "runtime"
@@ -19,6 +19,7 @@ import (
"strings" "strings"
"time" "time"
"github.com/dtoro/oikos/internal/actuator"
"github.com/dtoro/oikos/internal/config" "github.com/dtoro/oikos/internal/config"
"github.com/dtoro/oikos/internal/db" "github.com/dtoro/oikos/internal/db"
"github.com/dtoro/oikos/internal/db/sqlcgen" "github.com/dtoro/oikos/internal/db/sqlcgen"
@@ -981,28 +982,46 @@ func allowlistedScript(name string) bool {
return scriptNameRe.MatchString(name) return scriptNameRe.MatchString(name)
} }
// sshExec runs a command on a remote host over crypto/ssh via the shared
// actuator primitives. It replaced a fork of `os/exec ssh` so the scheduler,
// the MCP execution path, and the actuator share one dial/run/host-key
// implementation (plan E3). The host key is verified through the centralized
// actuator.HostKeyCallback seam. ctx bounds the running command; timeout
// bounds the dial.
func sshExec(ctx context.Context, host, port, user, cmd string, timeout time.Duration) ([]byte, error) { func sshExec(ctx context.Context, host, port, user, cmd string, timeout time.Duration) ([]byte, error) {
args := []string{ keyPath := sshKeyPath
"-o", "ConnectTimeout=" + strconv.Itoa(int(timeout.Seconds())), if keyPath == "" {
"-o", "StrictHostKeyChecking=no", // Preserve the old os/exec-ssh behavior of deferring to a default
"-o", "BatchMode=yes", // key when no explicit OIKOS_SSH_KEY_PATH is configured: the system
"-o", "UserKnownHostsFile=/dev/null", // ssh binary used the agent / ~/.ssh; crypto/ssh has no agent wiring,
"-o", "LogLevel=ERROR", // so fall back to SSH_KEY_PATH then ~/.ssh/id_rsa.
} keyPath = os.Getenv("SSH_KEY_PATH")
if sshKeyPath != "" { if keyPath == "" {
args = append(args, "-i", sshKeyPath) keyPath = os.Getenv("HOME") + "/.ssh/id_rsa"
}
if port != "" && port != "22" {
args = append(args, "-p", port)
}
args = append(args, "-l", user, host, cmd)
c := exec.CommandContext(ctx, "ssh", args...)
out, err := c.Output()
if err != nil {
var ee *exec.ExitError
if errors.As(err, &ee) {
return nil, fmt.Errorf("ssh %s: %v (stderr: %s)", host, err, string(ee.Stderr))
} }
}
signer, err := actuator.LoadSigner(keyPath)
if err != nil {
return nil, fmt.Errorf("ssh %s: %v", host, err)
}
p := 22
if port != "" {
if n, parseErr := strconv.Atoi(port); parseErr == nil && n > 0 {
p = n
}
}
client, err := actuator.Dial(ctx, actuator.DialOptions{
Host: host, Port: p, User: user, Signer: signer, Timeout: timeout,
})
if err != nil {
return nil, fmt.Errorf("ssh %s: %v", host, err)
}
defer client.Close()
// RunOutput (stdout-only) — the scheduler parses check output as JSON or
// matches it literally, so stderr must not be merged in (RunCombinedOutput
// is for the live-run display path in mcp/httpapi).
out, err := actuator.RunOutput(ctx, client, cmd)
if err != nil {
return nil, fmt.Errorf("ssh %s: %v", host, err) return nil, fmt.Errorf("ssh %s: %v", host, err)
} }
return out, nil return out, nil

View File

@@ -1,10 +1,8 @@
# 2026-08-05 — Backend evaluation: architecture, security, and reliability improvements # 2026-08-05 — Backend evaluation: architecture, security, and reliability improvements
Status: **In Progress** — Phase 0 (B1, B2, B4, B5, B6, B7) and Phase 2 (D1D5) Status: **In Progress** — Phase 0 (B1, B2, B4, B5, B6, B7), Phase 2 (D1D5),
complete; D1D5 were hardened across two `/review` passes (deploy lock, TOCTOU and Phase 3 code quality (E1E5) complete. B3 is a post-deploy operational step.
guard, token hygiene, XFF rightmost-hop, ctx-driven sweep). B3 is a post-deploy Remaining: Phase 1 security (C1C3) and Phase 46 backlog.
operational step. Remaining: Phase 1 security (C1C3), Phase 3 code quality
(E1E5), and Phase 46 backlog.
Scope: full evaluation of the oikos backend (Go binaries `oikos`, `nomos`, `webhook`, Scope: full evaluation of the oikos backend (Go binaries `oikos`, `nomos`, `webhook`,
Postgres/TimescaleDB, Docker deployment, MCP server) excluding frontend clients Postgres/TimescaleDB, Docker deployment, MCP server) excluding frontend clients
@@ -295,11 +293,12 @@ binary reads secrets from env vars or plaintext files.
## E. Code quality (medium) ## E. Code quality (medium)
### E1. Split monolithic files ### E1. Split monolithic files
| File | Lines | Split target | - **Status**: Done. All three splits complete.
|------|-------|-------------| | File | Lines → | Split target |
| `internal/mcp/tools.go` | 1774 | `entity_tools.go`, `ops_tools.go`, `knowledge_tools.go`, `analysis_tools.go` | |------|---------|-------------|
| `internal/httpapi/impl.go` | 1533 | Handler group files by domain (entities, executions, approvals, metrics, etc.) | | `internal/mcp/tools.go` | 1774 → 89 | `entity_tools.go`, `ops_tools.go`, `knowledge_tools.go`, `analysis_tools.go` |
| `cmd/nomos/main.go` | 1127 | `server.go`, `workers.go`, `mcp.go` (partially done — `agent.go` and `store.go` exist) | | `internal/httpapi/impl.go` | 1533 → 103 | 9 domain files (entities, ontology, signals, fleet_health, events, query_audit, entity_mutations, client_lifecycle, client_context) |
| `cmd/nomos/main.go` | 1127 → deleted | `server.go`, `workers.go`, `mcp.go` (agent.go and store.go were already split) |
### E2. Migrate raw pool.Exec queries to sqlc ### E2. Migrate raw pool.Exec queries to sqlc
- ~50% of DB access in HTTP/MCP handlers bypasses sqlc with raw `pool.Exec`/`pool.QueryRow`. - ~50% of DB access in HTTP/MCP handlers bypasses sqlc with raw `pool.Exec`/`pool.QueryRow`.
@@ -307,23 +306,40 @@ binary reads secrets from env vars or plaintext files.
compile-time validation. compile-time validation.
### E3. Unify SSH implementations ### E3. Unify SSH implementations
- Scheduler uses `os/exec ssh` (system binary), MCP/actuator uses `crypto/ssh`. - **Status**: Done (hardened after review)
- Unify on `crypto/ssh` throughout for consistency, testability, and connection - Scheduler used `os/exec ssh` (system binary), MCP/actuator used `crypto/ssh`.
multiplexing (single TCP connection, multiple sessions). - Unified on `crypto/ssh` with a shared `internal/actuator` package:
- Consider a shared SSH pool in `internal/actuator/` used by both scheduler and MCP. - `client.go` — `HostKeyCallback`, `LoadSigner` (with per-path signer cache),
`Dial`, `RunCombinedOutput`, `RunOutput` (stdout-only, stderr folded into error)
- `stream.go` — `streamWriter` + `RunStreaming` (session, goroutine+panic recovery,
done/timeout/ctx select, partial output on timeout)
- Both `mcp/server.go` and `httpapi/actuator.go` delegate to `actuator.RunStreaming`;
the scheduler's `sshExec` uses `actuator.Dial` + `actuator.RunOutput`.
- **Review fixes applied**:
- `RunOutput` preserves pre-E3 `exec.Cmd.Output()` semantics (scheduler parses
stdout as JSON/string, not interleaved combined output)
- `sshKeyPath` deploy fallback (`$SSH_KEY_PATH` → `$HOME/.ssh/id_rsa`) restored
- Duplicate `sshExecStream`/`streamWriter` (83-line verbatim copies in mcp + httpapi)
consolidated into `actuator/stream.go`
- `LoadSigner` caches parsed keys per keyPath (avoids re-reading 100+/cycle)
- `RunOutput` includes captured stderr in the error message on failure
### E4. Fix lifecycle attribute check ### E4. Fix lifecycle attribute check
- `internal/db/lifecycle.go`: `checkPrecondition` uses `strings.Contains(attrs, want)` - **Status**: Done
- `internal/db/lifecycle.go`: `checkPrecondition` used `strings.Contains(attrs, want)`
on raw JSONB text, bypassing the GIN index. on raw JSONB text, bypassing the GIN index.
- Parse attributes properly and use `@>` or `?` JSONB operators. - **Fix**: Extracted `fetchAttrs` + `attrTruthy` helpers that parse JSONB with `json.Unmarshal`
and use `@>` JSONB operator for precondition queries. Added `lifecycle_test.go` with
9+2 table-driven cases.
### E5. Add table-driven tests for core logic ### E5. Add table-driven tests for core logic
Priority packages (currently 0% coverage): - **Status**: Done
1. `internal/policy` — risk classification rules (table-driven with seed policy.yaml cases) Packages covered (previously 0%):
2. `internal/domain` — lifecycle state machine transitions 1. `internal/policy` — risk_test.go (62.9% → 64.7%)
3. `internal/scheduler` — check dispatch and signal resolution 2. `internal/ontology` — preconditions_test.go (50.5% → 63.1%)
4. `internal/ontology` — monitoring resolution and type tree traversal 3. `internal/checkdefaults` — build_test.go (26.5% → 52.5%)
5. `internal/checkdefaults` — check derivation from monitoring specs 4. `internal/actuator` — client_test.go (SSH key parsing, RunOutput)
5. `internal/db` — lifecycle_test.go (attrTruthy, precondition SQL)
## F. Performance (medium) ## F. Performance (medium)
@@ -402,17 +418,14 @@ Priority packages (currently 0% coverage):
3. **Phase 2 — Operational** (D1D5): CI pipeline, image versioning, rate 3. **Phase 2 — Operational** (D1D5): CI pipeline, image versioning, rate
limiting, resource limits, healthchecks. **Done.** limiting, resource limits, healthchecks. **Done.**
4. **Phase 3 — Code quality** (E1E5): File splits, sqlc migration, SSH 4. **Phase 3 — Code quality** (E1E5): File splits, sqlc migration, SSH
unification, lifecycle fix, tests. E1E3 are large refactors — do one unification, lifecycle fix, tests. **Done.**
file/area per commit.
5. **Phase 4 — Performance** (F1F4): SSH pooling, entity cache, trigram 5. **Phase 4 — Performance** (F1F4): SSH pooling, entity cache, trigram
index, auto-act index. index, auto-act index.
6. **Phase 5 — Observability** (G1G3): OTel tracing, Prometheus, offsite backups. 6. **Phase 5 — Observability** (G1G3): OTel tracing, Prometheus, offsite backups.
7. **Phase 6 — Infrastructure** (H1H4): Pin images, job queue, migration runner, 7. **Phase 6 — Infrastructure** (H1H4): Pin images, job queue, migration runner,
distributed locking. distributed locking.
Phase 0 is the gate. Once all secrets flow through Infisical, phases 12 can Phases 03 are complete. Phases 46 are backlog.
proceed. Phases 34 should wait for CI (D1) so refactors are validated.
Phases 56 are backlog.
--- ---

View File

@@ -21,7 +21,7 @@ went sideways, open an investigation.
| 2026-07-21 | [Frontend as OS + Apps — architecture audit & refactor](2026-07-21-frontend-os-apps-architecture.md) | Planned — Phase 1 ready | | 2026-07-21 | [Frontend as OS + Apps — architecture audit & refactor](2026-07-21-frontend-os-apps-architecture.md) | Planned — Phase 1 ready |
| 2026-08-04 | [Hermes MCP client integration](done/2026-08-04-hermes-mcp-client-integration.md) | Done — deployed | | 2026-08-04 | [Hermes MCP client integration](done/2026-08-04-hermes-mcp-client-integration.md) | Done — deployed |
| 2026-08-05 | [Agent execution safety: QEMU guest agent gate + host-mutation guard](done/2026-08-05-agent-execution-safety-qemu-guest-agent-gate.md) | Done — implemented (1b9c761) | | 2026-08-05 | [Agent execution safety: QEMU guest agent gate + host-mutation guard](done/2026-08-05-agent-execution-safety-qemu-guest-agent-gate.md) | Done — implemented (1b9c761) |
| 2026-08-05 | [Backend evaluation: architecture, security, and reliability improvements](2026-08-05-backend-evaluation-improvements.md) | In Progress — Phase 0 (B) + Phase 2 (D1D5) done; Phase 1 (C) pending | | 2026-08-05 | [Backend evaluation: architecture, security, and reliability improvements](2026-08-05-backend-evaluation-improvements.md) | In Progress — Phase 0 (B) + Phase 2 (D) + Phase 3 (E1E5) done; Phase 1 (C) pending |
## Done ## Done