Files
multica/server/internal/daemon/execenv/runtime_config.go
Multica Eve 0c2e48ded2 refactor: retire FF_RUNTIME_BRIEF_SLIM, make slim runtime brief the only path (MUL-4297)
The runtime_brief_slim feature flag has burned in; the slim runtime brief is now the sole path.

- execenv: buildMetaSkillContent / BuildCommentReplyInstructions delegate to the slim assembler unconditionally; delete the legacy verbose brief body and writeBackgroundTaskSafetyInstructions.
- Remove the runtime_brief_slim flag and the daemon-bound flag delivery subsystem built solely for it: execenv flag wiring (runtime_config_flag.go, server_snapshot_provider.go), the featureflagdispatch package, the DaemonFeatureFlagSnapshot heartbeat protocol field, and the server/daemon wiring in router.go, handler, daemon.go, main.go, cmd_daemon.go.
- Keep the generic server/pkg/featureflag engine (still used by composio_mcp_apps).
- Update tests to slim-only expectations and docs/feature-flags.md.

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-09 13:48:33 +08:00

380 lines
17 KiB
Go

package execenv
import (
"encoding/json"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"runtime"
"strings"
)
// runtimeMarkerBegin and runtimeMarkerEnd delimit the Multica-managed brief
// inside the runtime config file (CLAUDE.md / AGENTS.md). The
// markers exist so writeRuntimeConfigFile can:
//
// - preserve user-authored content in the same file (the user's repo may
// already ship a CLAUDE.md / AGENTS.md when the agent is pointed at a
// local_directory project resource),
// - replace the brief idempotently on subsequent runs in the same workdir
// instead of appending duplicate copies, and
// - leave a precise excision target for a future cleanup pass.
//
// HTML comments are used so the markers are inert in every Markdown renderer
// and harmless when fed to the agent as instructions. Changing the marker
// text is a breaking change for any file that already carries the previous
// markers — bump deliberately.
const (
runtimeMarkerBegin = "<!-- BEGIN MULTICA-RUNTIME (auto-managed; do not edit) -->"
runtimeMarkerEnd = "<!-- END MULTICA-RUNTIME -->"
// runtimeManagedSeparator is the fixed separator inserted between any
// pre-existing user content and the marker block whenever Inject
// appends to a file that already exists. The separator is considered
// part of the managed region: Cleanup strips it together with the
// block, so the file rolls back to its exact pre-injection bytes
// regardless of whether the user file ended with no newline, one
// newline, or multiple trailing newlines. Without a fixed-width
// separator the cleanup path would have to renormalise the user's
// trailing bytes and would leave a subtle but real diff every run
// (see MUL-2753 review on PR #3438).
//
// Cleanup distinguishes "file we created" (no managed separator
// precedes the block — write a missing file from scratch) from "file
// that pre-existed" (managed separator precedes the block) so the
// file's existence is preserved exactly across the inject→cleanup
// cycle, including empty / whitespace-only pre-existing files.
runtimeManagedSeparator = "\n\n"
)
// runtimeGOOS is the host-platform string used by buildMetaSkillContent and
// BuildCommentReplyInstructions to emit Windows-specific guidance. Defaults
// to runtime.GOOS; tests override it to exercise the cross-platform branches
// deterministically without having to run on every target OS.
var runtimeGOOS = runtime.GOOS
// sanitizeNameForBriefMarkdown turns a possibly-multiline display name into a
// single-line, plain-text token that is safe to embed inside markdown inline
// constructs (e.g. `**%s**`) in the agent brief. The brief is loaded as
// trusted instructions, so user-controlled name fields must not be able to
// introduce headings, lists, or close the surrounding bold span.
//
// CR/LF and other whitespace control bytes collapse to a single space; other
// C0 controls and DEL are dropped; markdown structural characters that have
// meaning in inline context (`*`, `_`, “ ` “, `\`, `[`, `]`, `<`) are
// backslash-escaped. Trailing whitespace is trimmed.
func sanitizeNameForBriefMarkdown(name string) string {
var b strings.Builder
b.Grow(len(name))
prevSpace := false
for _, r := range name {
switch {
case r == '\r' || r == '\n' || r == '\t' || r == '\v' || r == '\f':
if !prevSpace && b.Len() > 0 {
b.WriteByte(' ')
prevSpace = true
}
case r < 0x20 || r == 0x7f:
continue
case r == '*' || r == '_' || r == '`' || r == '\\' || r == '[' || r == ']' || r == '<':
b.WriteByte('\\')
b.WriteRune(r)
prevSpace = false
default:
b.WriteRune(r)
prevSpace = false
}
}
return strings.TrimSpace(b.String())
}
// sanitizeEmailForBrief returns the email verbatim when it is safe to embed
// inline in the brief, or "" when it carries a character a real address never
// has (whitespace, control chars, or a markdown-break risk). Unlike
// sanitizeNameForBriefMarkdown it does NOT backslash-escape markdown specials:
// an agent may want to match the initiator's address exactly, and escaping
// `_`/`+` would corrupt it, while a valid email can't contain a newline to
// inject a heading anyway. Emails are validated at signup, so this is
// defense-in-depth, not the primary guard. See MUL-2645.
func sanitizeEmailForBrief(email string) string {
email = strings.TrimSpace(email)
if email == "" || !strings.Contains(email, "@") {
return ""
}
for _, r := range email {
if r < 0x20 || r == 0x7f || r == ' ' || r == '\\' || r == '`' || r == '*' || r == '<' || r == '>' || r == '[' || r == ']' {
return ""
}
}
return email
}
// formatProjectResource renders a single resource as a human-readable bullet.
// Unknown resource types fall back to a JSON-encoded ref so the agent can
// still read what the user attached. New resource types should add a case
// here AND in the API validator (handler/project_resource.go).
func formatProjectResource(r ProjectResourceForEnv) string {
label := r.Label
switch r.ResourceType {
case "github_repo":
var payload struct {
URL string `json:"url"`
DefaultBranchHint string `json:"default_branch_hint,omitempty"`
Ref string `json:"ref,omitempty"`
}
_ = json.Unmarshal(r.ResourceRef, &payload)
out := fmt.Sprintf("**GitHub repo**: %s", payload.URL)
details := make([]string, 0, 2)
if payload.Ref != "" {
details = append(details, fmt.Sprintf("checkout ref: `%s`", payload.Ref))
}
if payload.DefaultBranchHint != "" {
details = append(details, fmt.Sprintf("default branch hint: `%s`", payload.DefaultBranchHint))
}
if len(details) > 0 {
out += " (" + strings.Join(details, ", ") + ")"
}
if label != "" {
out += " — " + label
}
return out
default:
ref := string(r.ResourceRef)
if ref == "" {
ref = "{}"
}
out := fmt.Sprintf("**%s**: `%s`", r.ResourceType, ref)
if label != "" {
out += " — " + label
}
return out
}
}
// InjectRuntimeConfig writes the meta skill content into the runtime-specific
// config file so the agent discovers its environment through its native mechanism.
//
// For Claude: writes {workDir}/CLAUDE.md (skills discovered natively from .claude/skills/)
// For Codex: writes {workDir}/AGENTS.md (skills discovered natively via CODEX_HOME)
// For Copilot: writes {workDir}/AGENTS.md (skills discovered natively from .github/skills/)
// For OpenCode: writes {workDir}/AGENTS.md (skills discovered natively from .opencode/skills/)
// For OpenClaw: writes {workDir}/AGENTS.md (skills discovered natively from {workDir}/skills/ via per-task openclaw-config.json that pins agents.defaults.workspace)
// For Hermes: writes {workDir}/AGENTS.md (skills fall back to .agent_context/skills/; AGENTS.md points there)
// For Pi: writes {workDir}/AGENTS.md (skills discovered natively from .pi/skills/)
// For Cursor: writes {workDir}/AGENTS.md (skills discovered natively from .cursor/skills/)
// For Kimi: writes {workDir}/AGENTS.md (Kimi Code CLI reads AGENTS.md natively; skills auto-discovered from project skills dirs)
// For Kiro: writes {workDir}/AGENTS.md (Kiro CLI reads AGENTS.md natively; skills auto-discovered from project skills dirs)
// For Qoder: writes {workDir}/AGENTS.md (skills discovered from .qoder/skills/, user-level ~/.qoder/skills is unaffected)
// For Antigravity: writes {workDir}/AGENTS.md (agy CLI reads AGENTS.md natively; skills discovered natively from .agents/skills/ — see https://antigravity.google/docs/gcli-migration)
// For Traecli: writes {workDir}/AGENTS.md (traecli reads .trae/rules/ not AGENTS.md, so the brief is delivered inline via providerNeedsInlineSystemPrompt; the file is written for parity/visibility only)
func InjectRuntimeConfig(workDir, provider string, ctx TaskContextForEnv) (string, error) {
content := buildMetaSkillContent(provider, ctx)
path := runtimeConfigPath(workDir, provider)
if path == "" {
// Unknown provider — skip config injection, prompt-only mode.
return content, nil
}
return content, writeRuntimeConfigFile(path, content)
}
// runtimeConfigPath returns the absolute path to the runtime config file that
// InjectRuntimeConfig writes for the given provider, or "" when the provider
// has no file-based config target. Centralising the mapping keeps Inject /
// Cleanup in lockstep — both paths consult the same table so a new provider
// added to one side cannot drift past the other.
func runtimeConfigPath(workDir, provider string) string {
switch provider {
case "claude", "codebuddy":
return filepath.Join(workDir, "CLAUDE.md")
case "codex", "copilot", "opencode", "openclaw", "hermes", "pi", "cursor", "kimi", "kiro", "antigravity", "qoder", "traecli":
return filepath.Join(workDir, "AGENTS.md")
default:
return ""
}
}
// writeRuntimeConfigFile writes the Multica runtime brief to path without
// clobbering any user-authored content already present. Behaviour by file
// state:
//
// - file missing → create the file containing only the marker block, no
// leading separator. Cleanup detects the absence of the separator and
// restores the missing-file state by removing the file outright.
// - file present (any content, including empty), no marker block →
// append `<runtimeManagedSeparator>` + the marker block. The
// separator's bytes are part of the managed region so Cleanup can
// restore the user's pre-injection bytes exactly (no trailing-newline
// normalisation, no surprises for files that ended without a newline
// or with extra trailing newlines).
// - file present, marker block already there → replace the body between
// the markers in place so repeated runs in the same workdir don't grow
// the file unboundedly. The pre-block content (including any managed
// separator established by the first inject) is preserved verbatim.
//
// The previous implementation called os.WriteFile unconditionally, which
// silently truncated a repository's CLAUDE.md / AGENTS.md the
// first time the agent was pointed at the user's own directory via the
// local_directory project resource flow. See MUL-2753.
func writeRuntimeConfigFile(path, brief string) error {
block := runtimeMarkerBegin + "\n" + strings.TrimRight(brief, "\n") + "\n" + runtimeMarkerEnd + "\n"
existing, err := os.ReadFile(path)
if errors.Is(err, fs.ErrNotExist) {
return os.WriteFile(path, []byte(block), 0o644)
}
if err != nil {
return fmt.Errorf("read existing runtime config %s: %w", path, err)
}
existingStr := string(existing)
if start, end, ok := locateMarkerBlock(existingStr); ok {
// Replace the existing block in place. locateMarkerBlock already
// consumes the trailing newline that closed the previous block, so
// successive runs don't accumulate blank lines around the block.
// The managed separator (if any) lives in existingStr[:start] and
// is preserved untouched.
newContent := existingStr[:start] + block + existingStr[end:]
return os.WriteFile(path, []byte(newContent), 0o644)
}
// No marker block present. Append the fixed managed separator followed
// by the block. The separator is unconditional — including for files
// that already end in two or more newlines — so the byte boundary
// between user content and the managed region is deterministic, which
// is what lets Cleanup roll back to the user's exact original bytes.
return os.WriteFile(path, []byte(existingStr+runtimeManagedSeparator+block), 0o644)
}
// locateMarkerBlock finds the [start, end) byte range of the Multica marker
// block inside content. The returned `end` is one past the block's trailing
// newline (if any) so callers can splice the block out without leaving an
// orphan blank line behind.
//
// The end marker is searched for strictly after the begin marker. This
// matters for two malformed cases that the previous naive `strings.Index`
// pair would mishandle:
//
// - User content carries a stray `<!-- END MULTICA-RUNTIME -->` (e.g. a
// documentation snippet showing what the wire format looks like) before
// any begin marker. The naive parser would find that end and reject the
// block (`endIdx > startIdx` false), then append a fresh block — and
// since the stray end stays in place, every subsequent run would append
// yet another block, growing the file unboundedly.
// - A previous run crashed between writing begin and end and left the file
// with a half-block. The naive parser would not find an end, fall
// through to the append branch, and stack a new block after the
// half-block. Treating "begin found, no end after" as "the block ends
// at EOF" makes the next write replace the half-block in place.
func locateMarkerBlock(content string) (start, end int, found bool) {
start = strings.Index(content, runtimeMarkerBegin)
if start < 0 {
return 0, 0, false
}
afterBegin := start + len(runtimeMarkerBegin)
endRel := strings.Index(content[afterBegin:], runtimeMarkerEnd)
if endRel < 0 {
// Malformed — no end marker after begin. Treat the rest of the file
// as the block so the next write replaces it cleanly instead of
// stacking another block beneath the half-block.
return start, len(content), true
}
end = afterBegin + endRel + len(runtimeMarkerEnd)
if end < len(content) && content[end] == '\n' {
end++
}
return start, end, true
}
// CleanupRuntimeConfig excises the Multica marker block from the runtime
// config file for the given provider and restores the file to its exact
// pre-injection state, byte for byte. The cleanup is the second half of
// the contract `writeRuntimeConfigFile` establishes: together they must
// round-trip a user's local repository config across an arbitrary number
// of Multica runs without ever touching a single non-managed byte.
//
// Behaviour, mirroring the three Inject states:
//
// - file has no marker block → no-op (nothing was ever injected here);
// - block is at the start of the file with no preceding managed
// separator → the file was created by Inject from a missing-file
// state. Remove the file outright so the post-cleanup directory
// listing is byte-identical to the pre-Inject one.
// - block is preceded by the fixed managed separator → strip the
// separator together with the block; whatever remains (which may be
// an empty pre-existing file, a whitespace-only file, or arbitrary
// user content) is the user's original file, written back verbatim
// with NO trailing-newline normalisation and NO TrimSpace-based file
// removal heuristic. Both of those were sources of subtle diff in
// PR #3438 review feedback.
//
// Required for the local_directory flow (WorkDir is the user's own repo):
// without this pass, a manual `claude` / `codex` run started by
// the user inside the same directory after a Multica task would pick up
// the stale brief and act on the previous task's issue id, trigger
// comment id, and reply rules. Cloud workspace runs never trigger this
// pollution because their workdir is daemon scratch that the GC loop
// deletes wholesale; the daemon skips this Cleanup on those workdirs.
//
// Missing files, unknown providers, and files without a marker block are
// no-ops — Cleanup is safe to call defensively.
func CleanupRuntimeConfig(workDir, provider string) error {
path := runtimeConfigPath(workDir, provider)
if path == "" {
return nil
}
existing, err := os.ReadFile(path)
if errors.Is(err, fs.ErrNotExist) {
return nil
}
if err != nil {
return fmt.Errorf("read runtime config %s: %w", path, err)
}
existingStr := string(existing)
start, end, ok := locateMarkerBlock(existingStr)
if !ok {
return nil
}
pre := existingStr[:start]
post := existingStr[end:]
// Detect — and strip — the fixed managed separator that Inject puts
// immediately before the block whenever it appended to a file that
// pre-existed. The absence of the separator is the marker that says
// "Inject created this file from scratch", which is the only case
// where Cleanup is allowed to delete the file.
hadManagedSeparator := strings.HasSuffix(pre, runtimeManagedSeparator)
if hadManagedSeparator {
pre = pre[:len(pre)-len(runtimeManagedSeparator)]
}
remainder := pre + post
if !hadManagedSeparator && remainder == "" {
// Inject created the file (no managed separator → block was the
// only content). Restore the missing-file state.
if err := os.Remove(path); err != nil && !errors.Is(err, fs.ErrNotExist) {
return fmt.Errorf("remove runtime config %s: %w", path, err)
}
return nil
}
// File pre-existed (possibly empty, possibly whitespace-only,
// possibly with user content) — write the remainder back exactly,
// without any normalisation. An empty `remainder` here means the
// user's original file was empty; we still write it (zero-byte file)
// so the file's existence is preserved.
return os.WriteFile(path, []byte(remainder), 0o644)
}
// buildMetaSkillContent generates the meta skill markdown that teaches the
// agent about the Multica runtime environment and available CLI tools.
//
// The brief is assembled by buildMetaSkillContentSlim (runtime_config_sections.go),
// which applies kind-driven section gating + per-section prose compression.
// This used to be gated behind the `runtime_brief_slim` feature flag against a
// legacy verbose brief; the flag has been retired (MUL-4297) and the slim brief
// is now the only path.
func buildMetaSkillContent(provider string, ctx TaskContextForEnv) string {
return buildMetaSkillContentSlim(provider, ctx)
}