diff --git a/CHANGELOG.md b/CHANGELOG.md index ccd0cac..e1de0d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,8 @@ On a release, the maintainer moves the accumulated `[Unreleased]` entries into a ## [Unreleased] +- **frontdoor-parity** — Centralize the native runtime capabilities shared by run/watch/resume/swarm, chat/TUI, and serve (advisor budget, repo orientation, proactive context window, live code intelligence, memory/lessons, and trusted steering) behind one configurator, with a constructor-level parity test across all three real builders so a one-door omission cannot ship inert again. _Owns:_ `cmd/nilcore/{native_runtime.go,native_runtime_test.go,chat.go,main.go}`, `CHANGELOG.md`. _(architecture hardening)_ + - **security-release-hardening** — Pin the build/release floor to patched Go 1.25.12 and `x/sys` 0.44.0, add a pinned reachable-vulnerability CI gate, make release publication refuse missing/corrupt platform assets or checksum records, and add real four-target release + installer E2Es proving valid install plus fail-closed partial-release, missing-checksum, and tampered-binary paths; refresh the public toolchain and state snapshot. _Owns:_ `go.mod`, `go.sum`, `.github/workflows/{ci,release}.yml`, `scripts/{install,verify-release-assets}.sh`, `test/{install,release-assets}-e2e.sh`, `README.md`, `STATE.md`, `docs/PREREQUISITES.md`. _(security / release hardening)_ - **docs(state-of-the-project): reconcile every document with the code as it actually is.** A ground-truth pack was extracted from the tree (CLI surface, the full env-var inventory incl. the prefix-constructed `NILCORE_{CODEX,CLAUDE}_{MODEL,EFFORT}`, package inventory + metrics, emitted event kinds, invariant enforcement sites, shipped-vs-gated status), then all 30 docs were audited against it and each repair was **independently refuted** by a second reader; 15 surviving defects were re-fixed. Headline corrections: `router.Classify(goal)` returns `run|build|swarm` — **not** `|decompose` (`Decompose` is a fourth `Preset`, opt-in only, excluded from `All()`), a claim three contract files asserted wrongly; `docs/ARCHITECTURE.md` overstated the container egress control as SSRF-proof/IP-layer when `AllowEgressVia` only sets `--network bridge` + `HTTP(S)_PROXY` and no packet filter exists — the proxy is the only *sanctioned* way out, not a hard network wall; metrics refreshed everywhere (120 packages, 375 source + 406 test files, ~89.8K non-test LOC); the six `NILCORE_OPENROUTER_*` / `NILCORE_COMPAT_*` vars documented only in suffix shorthand are now spelled out; `docs/REFERENCE.md` gained an explicit upgrade note for the two settings whose MEANING changed at `573a4df` (`NILCORE_SELFIMPROVE_AUTOAPPROVE=1` was a no-op and now really merges; swarm shards now reach their preset's declared hosts). Roadmap/plan docs keep their plans but now carry accurate SHIPPED / PARTIALLY SHIPPED / NOT-BUILT status and no longer name pruned symbols as live. `CHANGELOG.md` history untouched (append-only). Markdown only — no `.go`, `Makefile` or CI file changed. _Owns:_ `*.md`, `docs/**`. _(Phase 16 — documentation)_ diff --git a/cmd/nilcore/chat.go b/cmd/nilcore/chat.go index ca73375..f6648d2 100644 --- a/cmd/nilcore/chat.go +++ b/cmd/nilcore/chat.go @@ -40,7 +40,6 @@ import ( "syscall" "time" - "nilcore/internal/advisor" "nilcore/internal/agent" "nilcore/internal/backend" "nilcore/internal/blastbudget" @@ -1352,14 +1351,10 @@ func chatNativeBackend(d chatDeps, prov model.Provider, adv advisorCfg, box sand MaxSteps: *d.flags.common.maxSteps, Seed: in.Seed, } - // Repo orientation + window awareness (upgrade program), exactly as buildBackend - // (run/watch) and the serve backend already wire: without these the interactive - // chat/TUI drive starts BLIND (no repo map ⇒ ls/cat structure discovery on every - // first step) and never proactively compacts (nil CtxWindow ⇒ only the one-shot - // overflow-400 recovery). box.Workdir() is the same per-task worktree the backend - // is built against; both are nil-safe seams. - n.RepoContext = func(context.Context) string { return repoMap(box.Workdir(), repoMapBudget) } - n.CtxWindow = meter.CtxWindow + configureNativeRuntime(n, nativeRuntimeConfig{ + Provider: prov, Advisor: adv, Box: box, Memory: d.mem, + Project: d.baseRepo, SteeringDir: d.baseRepo, + }) if in.Inbox != nil { n.Inbox = in.Inbox } @@ -1374,29 +1369,6 @@ func chatNativeBackend(d chatDeps, prov model.Provider, adv advisorCfg, box sand if in.AskUser != nil { n.AskUser = in.AskUser } - if adv.prov != nil { - // A fresh advisor per drive so its per-drive consult ceiling is honored, - // exactly as the run path's buildBackend does. Metered against the conversation - // budget wall (§6/§7) — a raw adv.prov would let strong-model consults escape it. - n.Advisor = advisor.New(meteredAdvisor(prov, adv.prov), adv.maxCalls) - n.EscalateAfter = adv.escalateAfter - } - // Live incremental code-intelligence (P3-T16), opt-in via NILCORE_LIVE_INDEX: - // the conversational loop gets the same worktree-aware `live` tool the run path - // has — previously only `buildBackend` (run/watch/propose-edit) wired it, so the - // advertised front door silently lacked it. Off by default (nil seam). - if envOptIn("NILCORE_LIVE_INDEX") { - n.LiveSession = liveSession(d.mem, d.baseRepo) - } - // Cross-project memory + distilled lessons: the conversational front door reads the - // same merged view the run path does. Previously only buildBackend wired this, so - // the door people actually use never saw the agent's own distilled scars. - attachMemoryContext(n, d.mem, d.baseRepo) - // Operator steering (P10-T01): an authoritative project steering file - // (NILCORE.md / AGENTS.md) committed at the repo root is loaded ONCE at launch - // from the operator's own repo — front-door origin, never tool/inbox text — and - // prepended as TRUSTED instructions (the I7 exception). nil/empty ⇒ byte-identical. - attachSteering(n, d.baseRepo) return n } diff --git a/cmd/nilcore/main.go b/cmd/nilcore/main.go index 9a3b659..dcceaf9 100644 --- a/cmd/nilcore/main.go +++ b/cmd/nilcore/main.go @@ -30,7 +30,6 @@ import ( "syscall" "time" - "nilcore/internal/advisor" "nilcore/internal/agent" "nilcore/internal/backend" "nilcore/internal/blastbudget" @@ -1779,6 +1778,10 @@ func serveNativeBackend(d serveDeps, prov model.Provider, adv advisorCfg, box sa MaxSteps: *d.flags.maxSteps, Seed: in.Seed, } + configureNativeRuntime(n, nativeRuntimeConfig{ + Provider: prov, Advisor: adv, Box: box, Memory: d.mem, + Project: d.baseRepo, SteeringDir: d.baseRepo, + }) if in.Inbox != nil { n.Inbox = in.Inbox } @@ -1794,26 +1797,6 @@ func serveNativeBackend(d serveDeps, prov model.Provider, adv advisorCfg, box sa if in.AskUser != nil { n.AskUser = in.AskUser } - // Same orientation + window seams as buildBackend's native case: serve/chat - // drives start with the map and compact before overflow, exactly like run. - n.RepoContext = func(context.Context) string { return repoMap(box.Workdir(), repoMapBudget) } - n.CtxWindow = meter.CtxWindow - if adv.prov != nil { - // Meter the advisor against the same conversation/thread budget wall as the - // main provider (§6/§7) — a raw adv.prov would let strong-model consults escape - // the ceiling. - n.Advisor = advisor.New(meteredAdvisor(prov, adv.prov), adv.maxCalls) - n.EscalateAfter = adv.escalateAfter - } - // Live incremental code-intelligence (P3-T16), opt-in via NILCORE_LIVE_INDEX — - // the serve loop gets the same `live` tool as the run/chat paths. - if envOptIn("NILCORE_LIVE_INDEX") { - n.LiveSession = liveSession(d.mem, d.baseRepo) - } - // Cross-project memory + distilled lessons, and the operator's steering file — - // both reach serve drives now, exactly as they reach run/watch and chat. - attachMemoryContext(n, d.mem, d.baseRepo) - attachSteering(n, d.baseRepo) // Self-timer (serve-only): the `sleep` tool arms a durable wake for this thread. // nil ⇒ no `sleep` tool advertised (byte-identical) — e.g. no checkpointer wired. n.Wake = wakeArm @@ -2274,12 +2257,6 @@ func envFactory(c commonFlags, prov model.Provider, cred func(string) string, ad box := attachBlast(selectSandbox(*c.sandboxPref, *c.runtime, *c.image, dir), blast) v := orchestratorVerifier(box, *c.checkCmd, log, *c.logPath) be := buildBackend(*c.backendName, prov, cred, adv, box, v, log, *c.maxSteps, mem, project, cfg) - // Operator steering (P10-T01): a committed NILCORE.md / AGENTS.md is present in - // the worktree checkout; load it once and prepend as trusted instructions on - // the native backend. nil/empty ⇒ byte-identical; only the native loop reads it. - if n, ok := be.(*backend.Native); ok { - attachSteering(n, dir) - } return agent.Env{Backend: be, Verifier: v, Box: box} } } @@ -2307,11 +2284,6 @@ func multiEnvFactory(c commonFlags, b boot, log *eventlog.Log, mem *memory.Memor box := attachBlast(selectSandbox(*c.sandboxPref, *c.runtime, *c.image, dir), blast) v := orchestratorVerifier(box, *c.checkCmd, log, *c.logPath) be := buildBackend(name, prov, b.cred, adv, box, v, log, *c.maxSteps, mem, project, b.cfg) - // Operator steering parity with envFactory: load committed NILCORE.md/AGENTS.md - // once for the native backend (nil/empty ⇒ byte-identical; only native reads it). - if n, ok := be.(*backend.Native); ok { - attachSteering(n, dir) - } return agent.Env{Backend: be, Verifier: v, Box: box} } } @@ -2441,26 +2413,14 @@ func buildBackend(name string, prov model.Provider, cred func(string) string, ad CommandGuard: policy.DefaultCommandPolicy().Check, MaxSteps: maxSteps, } - // A fresh advisor per task so its per-task consult ceiling is honored. Metered - // against the main provider's wall (§6/§7) so strong-model consults can't escape it. - if adv.prov != nil { - n.Advisor = advisor.New(meteredAdvisor(prov, adv.prov), adv.maxCalls) - n.EscalateAfter = adv.escalateAfter - } - attachMemoryContext(n, mem, project) - // Repo orientation + window awareness (upgrade program): the map spares the - // first steps of every drive from ls/cat structure discovery, and the window - // resolver lets the loop compact BEFORE a context overflow instead of dying - // on the 400. Both nil-safe seams; box.Workdir() is the per-task worktree. - n.RepoContext = func(context.Context) string { return repoMap(box.Workdir(), repoMapBudget) } - n.CtxWindow = meter.CtxWindow - // Live incremental code-intelligence (P3-T16), opt-in via NILCORE_LIVE_INDEX: - // the loop gets a worktree-aware `live` tool whose graph re-indexes edits - // incrementally and fuses project memory. Off by default (nil ⇒ byte-identical; - // no full per-run index cost unless requested). - if envOptIn("NILCORE_LIVE_INDEX") { - n.LiveSession = liveSession(mem, project) + steeringDir := project + if box != nil && box.Workdir() != "" { + steeringDir = box.Workdir() } + configureNativeRuntime(n, nativeRuntimeConfig{ + Provider: prov, Advisor: adv, Box: box, Memory: mem, + Project: project, SteeringDir: steeringDir, + }) return n } } diff --git a/cmd/nilcore/native_runtime.go b/cmd/nilcore/native_runtime.go new file mode 100644 index 0000000..92811ce --- /dev/null +++ b/cmd/nilcore/native_runtime.go @@ -0,0 +1,69 @@ +package main + +// The native loop is constructed by three production front-door families: +// run/watch/propose-edit (buildBackend), terminal chat/TUI (chatNativeBackend), and +// channel serve (serveNativeBackend). These shared seams used to be wired independently, +// which repeatedly let a capability ship on one door while remaining inert on another. +// Keep the front-door-specific tools, inbox, emitter, ask, and wake seams at their call +// sites; configure every cross-cutting runtime capability exactly once here. + +import ( + "context" + + "nilcore/internal/advisor" + "nilcore/internal/backend" + "nilcore/internal/memory" + "nilcore/internal/meter" + "nilcore/internal/model" + "nilcore/internal/sandbox" +) + +type nativeRuntimeConfig struct { + Provider model.Provider + Advisor advisorCfg + Box sandbox.Sandbox + Memory *memory.Memory + Project string + SteeringDir string +} + +// configureNativeRuntime applies the cross-cutting seams every native front door must +// share. Optional features remain nil/off byte-identically: no advisor means no advisor, +// nil memory means no memory context, NILCORE_LIVE_INDEX off means no live session, and +// an absent steering file means no trusted steering context. +func configureNativeRuntime(n *backend.Native, cfg nativeRuntimeConfig) { + if n == nil { + return + } + + // Repo orientation and proactive context compaction are baseline runtime features. + // The sandbox worktree is the model's actual view; fall back to the project root only + // for a defensive nil-box construction in tests or future read-only hosts. + workdir := cfg.Project + if cfg.Box != nil && cfg.Box.Workdir() != "" { + workdir = cfg.Box.Workdir() + } + if workdir != "" { + n.RepoContext = func(context.Context) string { return repoMap(workdir, repoMapBudget) } + } + n.CtxWindow = meter.CtxWindow + + // A fresh advisor per drive preserves the per-drive call ceiling. If the main + // provider is metered, meteredAdvisor shares its ledger so strong-model spend cannot + // escape the one budget wall. + if cfg.Advisor.prov != nil { + n.Advisor = advisor.New(meteredAdvisor(cfg.Provider, cfg.Advisor.prov), cfg.Advisor.maxCalls) + n.EscalateAfter = cfg.Advisor.escalateAfter + } + + // Live code intelligence is opt-in and worktree-aware; memory and steering are the + // same shared context sources on every door. SteeringDir stays explicit because chat + // loads from the principal's repo while run loads from its disposable worktree. + if envOptIn("NILCORE_LIVE_INDEX") && cfg.Project != "" { + n.LiveSession = liveSession(cfg.Memory, cfg.Project) + } + attachMemoryContext(n, cfg.Memory, cfg.Project) + if cfg.SteeringDir != "" { + attachSteering(n, cfg.SteeringDir) + } +} diff --git a/cmd/nilcore/native_runtime_test.go b/cmd/nilcore/native_runtime_test.go new file mode 100644 index 0000000..8fd0cce --- /dev/null +++ b/cmd/nilcore/native_runtime_test.go @@ -0,0 +1,90 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "nilcore/internal/backend" + "nilcore/internal/memory" + "nilcore/internal/onboard" + "nilcore/internal/sandbox" + "nilcore/internal/session" + "nilcore/internal/verify" +) + +// TestNativeFrontDoorsShareRuntimeCapabilities is the constructor-level parity gate. +// It drives the real run, chat/TUI, and serve builders and asserts every one receives +// the same cross-cutting runtime seams. Before configureNativeRuntime existed, omitting +// one of these assignments on a single front door compiled and its isolated tests stayed +// green; this test makes that class of built-but-inert regression discriminating. +func TestNativeFrontDoorsShareRuntimeCapabilities(t *testing.T) { + t.Setenv("NILCORE_LIVE_INDEX", "0") + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "NILCORE.md"), []byte("shared steering marker\n"), 0o644); err != nil { + t.Fatal(err) + } + + steps := 8 + prov := &fakeProvider{id: "main"} + advProv := &fakeProvider{id: "advisor"} + adv := advisorCfg{prov: advProv, maxCalls: 2, escalateAfter: 3} + mem := memory.New(nil) // construction-only: the callback is asserted, never invoked + box := sandbox.NewContainer("podman", "img", dir) + + run, ok := buildBackend("native", prov, func(string) string { return "" }, adv, box, + verify.Pass{}, nil, steps, mem, dir, onboard.Config{}).(*backend.Native) + if !ok { + t.Fatal("buildBackend(native) did not return *backend.Native") + } + + chat := chatNativeBackend(chatDeps{ + flags: newChatFlags(dir), provider: prov, baseRepo: dir, mem: mem, + }, prov, adv, box, verify.Pass{}, session.NativeRun{Mode: session.ModeExecute}) + + serve := serveNativeBackend(serveDeps{ + flags: commonFlags{maxSteps: &steps}, provider: prov, baseRepo: dir, mem: mem, + }, prov, adv, box, verify.Pass{}, session.NativeRun{Mode: session.ModeExecute}, nil) + + for name, n := range map[string]*backend.Native{"run": run, "chat/tui": chat, "serve": serve} { + t.Run(name, func(t *testing.T) { + if n.RepoContext == nil { + t.Fatal("RepoContext is not wired") + } + if n.CtxWindow == nil { + t.Fatal("CtxWindow is not wired") + } + if n.Advisor == nil || n.EscalateAfter != adv.escalateAfter { + t.Fatalf("advisor parity lost: advisor=%v escalate_after=%d", n.Advisor, n.EscalateAfter) + } + if n.MemoryContext == nil { + t.Fatal("MemoryContext is not wired") + } + if n.SteeringContext == nil || !strings.Contains(n.SteeringContext(), "shared steering marker") { + t.Fatal("SteeringContext is not wired from the principal repo") + } + if n.LiveSession != nil { + t.Fatal("NILCORE_LIVE_INDEX=0 must keep LiveSession disabled") + } + }) + } + + // The opt-in live-index seam must also reach every constructor through the same + // configurator. This is a second, positive control; merely asserting the default-off + // path would not catch a regression that dropped LiveSession from the helper itself. + t.Setenv("NILCORE_LIVE_INDEX", "1") + liveRun := buildBackend("native", prov, func(string) string { return "" }, adv, box, + verify.Pass{}, nil, steps, mem, dir, onboard.Config{}).(*backend.Native) + liveChat := chatNativeBackend(chatDeps{ + flags: newChatFlags(dir), provider: prov, baseRepo: dir, mem: mem, + }, prov, adv, box, verify.Pass{}, session.NativeRun{Mode: session.ModeExecute}) + liveServe := serveNativeBackend(serveDeps{ + flags: commonFlags{maxSteps: &steps}, provider: prov, baseRepo: dir, mem: mem, + }, prov, adv, box, verify.Pass{}, session.NativeRun{Mode: session.ModeExecute}, nil) + for name, n := range map[string]*backend.Native{"run": liveRun, "chat/tui": liveChat, "serve": liveServe} { + if n.LiveSession == nil { + t.Errorf("%s: NILCORE_LIVE_INDEX=1 did not wire LiveSession", name) + } + } +}