diff --git a/Makefile b/Makefile index d8370bb3..fcbc8032 100644 --- a/Makefile +++ b/Makefile @@ -22,10 +22,11 @@ deps: ## Download Go dependencies build: ## Build the mnemon binary go build -ldflags "$(LDFLAGS)" -o $(BINARY) . -harness-build: ## Build the harness binaries (mnemon-harness local plane + mnemon-hub remote hub + mnemond local governance daemon) +harness-build: ## Build the harness binaries (mnemon-harness local plane + mnemon-hub remote hub + mnemond local governance daemon + test-only acceptance runner) go build -ldflags "$(LDFLAGS)" -o mnemon-harness ./harness/cmd/mnemon-harness go build -ldflags "$(LDFLAGS)" -o mnemon-hub ./harness/cmd/mnemon-hub go build -ldflags "$(LDFLAGS)" -o mnemond ./harness/cmd/mnemond + go build -ldflags "$(LDFLAGS)" -o mnemon-acceptance ./harness/cmd/mnemon-acceptance # ── Install / Uninstall ───────────────────────────────────────────── diff --git a/harness/cmd/mnemon-harness/acceptance.go b/harness/cmd/mnemon-acceptance/acceptance.go similarity index 96% rename from harness/cmd/mnemon-harness/acceptance.go rename to harness/cmd/mnemon-acceptance/acceptance.go index 6a373a74..d3db9968 100644 --- a/harness/cmd/mnemon-harness/acceptance.go +++ b/harness/cmd/mnemon-acceptance/acceptance.go @@ -39,12 +39,6 @@ var ( acceptanceTurnTimeout time.Duration ) -var acceptanceCmd = &cobra.Command{ - Use: "acceptance", - Short: "Run hidden acceptance gates", - Hidden: true, -} - var acceptanceR1CodexCmd = &cobra.Command{ Use: "r1-codex", Short: "Run the R1 real Codex appserver acceptance gate", @@ -81,8 +75,7 @@ func init() { acceptanceR1CodexCmd.Flags().BoolVar(&acceptanceAgentTurns, "agent-turns", false, "run real model turns that write governed R1 events") acceptanceR1CodexCmd.Flags().BoolVar(&acceptanceSyncArm, "sync-arm", false, "run the 6B real sync/import arm after the local arm") acceptanceR1CodexCmd.Flags().DurationVar(&acceptanceTurnTimeout, "turn-timeout", 5*time.Minute, "timeout per real agent turn") - acceptanceCmd.AddCommand(acceptanceR1CodexCmd) - rootCmd.AddCommand(acceptanceCmd) + rootCmd.AddCommand(acceptanceR1CodexCmd) } type r1CodexAcceptanceOptions struct { @@ -381,14 +374,31 @@ func runR1CodexAcceptance(ctx context.Context, opts r1CodexAcceptanceOptions) (r } func installAcceptanceHarnessBinary(runRoot string) (string, error) { - exe, err := os.Executable() - if err != nil { - return "", err - } binDir := filepath.Join(runRoot, "bin") if err := os.MkdirAll(binDir, 0o755); err != nil { return "", err } + if sourceRoot, ok := acceptanceSourceRoot(); ok { + targets := map[string]string{ + "mnemon-harness": "./harness/cmd/mnemon-harness", + "mnemond": "./harness/cmd/mnemond", + "mnemon-hub": "./harness/cmd/mnemon-hub", + } + for name, pkg := range targets { + target := filepath.Join(binDir, name) + cmd := exec.Command("go", "build", "-o", target, pkg) + cmd.Dir = sourceRoot + out, err := cmd.CombinedOutput() + if err != nil { + return "", fmt.Errorf("build acceptance product binary %s: %w: %s", name, err, strings.TrimSpace(string(out))) + } + } + return binDir, nil + } + exe, err := os.Executable() + if err != nil { + return "", err + } target := filepath.Join(binDir, "mnemon-harness") in, err := os.Open(exe) if err != nil { @@ -409,6 +419,27 @@ func installAcceptanceHarnessBinary(runRoot string) (string, error) { return binDir, nil } +func acceptanceSourceRoot() (string, bool) { + cwd, err := os.Getwd() + if err != nil { + return "", false + } + for dir := cwd; ; dir = filepath.Dir(dir) { + if fileExists(filepath.Join(dir, "go.mod")) && fileExists(filepath.Join(dir, "harness", "cmd", "mnemon-harness", "root.go")) { + return dir, true + } + parent := filepath.Dir(dir) + if parent == dir { + return "", false + } + } +} + +func fileExists(path string) bool { + info, err := os.Stat(path) + return err == nil && !info.IsDir() +} + func prepareR1AcceptanceRunRoot(runRoot string) error { testdataRoot, err := physicalAcceptancePath(".testdata") if err != nil { @@ -484,6 +515,7 @@ func setupR1CodexAgents(runRoot, binDir, controlURL string, count int, sourceCod Host: "codex", ControlURL: controlURL, Principal: principal, + HarnessBin: filepath.Join(binDir, "mnemon-harness"), ProjectRoot: workspace, UseToken: true, }); err != nil { @@ -653,7 +685,8 @@ func startR1CodexAppserver(agent *r1CodexAgent, command string) error { func initializeR1CodexAgent(agent *r1CodexAgent, turnTimeout time.Duration) (r1CodexAgentReport, json.RawMessage, error) { initResp, err := agent.server.Request("initialize", map[string]any{ - "clientInfo": map[string]any{"name": "mnemon-r1-codex-acceptance", "version": version}, + "clientInfo": map[string]any{"name": "mnemon-r1-codex-acceptance", "version": version}, + "capabilities": codexAppServerCapabilities(), }, 30*time.Second) if err != nil { return r1CodexAgentReport{}, nil, fmt.Errorf("%s: initialize: %w", agent.principal, err) @@ -696,6 +729,13 @@ func initializeR1CodexAgent(agent *r1CodexAgent, turnTimeout time.Duration) (r1C return report, hooksRaw, nil } +func codexAppServerCapabilities() map[string]any { + return map[string]any{ + "experimentalApi": true, + "requestAttestation": false, + } +} + func r1AcceptanceDeveloperInstructions(principal string) string { return fmt.Sprintf(`You are %s in a Mnemon R1 real Codex cluster acceptance run. Follow the managed Mnemon GUIDE and the mnemon-observe skill. Read governed context when it is relevant, then write governed events through Local Mnemon from the shell. @@ -1136,6 +1176,7 @@ func setupR1CodexSyncAgents(ctx context.Context, runRoot, binDir string, hub r1S Host: "codex", ControlURL: localURL, Principal: principal, + HarnessBin: filepath.Join(binDir, "mnemon-harness"), ProjectRoot: workspace, UseToken: true, }); err != nil { @@ -1242,14 +1283,22 @@ func waitForR1DerivedEventPresentation(controlURL, token string, wants []string, } func runR1Turn(agent *r1CodexAgent, prompt string, timeout time.Duration) (string, error) { + return runR1TurnWithAdditionalContext(agent, prompt, timeout, nil) +} + +func runR1TurnWithAdditionalContext(agent *r1CodexAgent, prompt string, timeout time.Duration, additionalContext map[string]any) (string, error) { before := agent.server.NotificationCount() - if _, err := agent.server.Request("turn/start", map[string]any{ + params := map[string]any{ "threadId": agent.threadID, "input": []map[string]any{{"type": "text", "text": prompt}}, "cwd": agent.workspace, "approvalPolicy": "never", "sandboxPolicy": map[string]any{"type": "dangerFullAccess"}, - }, 30*time.Second); err != nil { + } + if len(additionalContext) > 0 { + params["additionalContext"] = additionalContext + } + if _, err := agent.server.Request("turn/start", params, 30*time.Second); err != nil { return "", fmt.Errorf("%s: turn/start: %w", agent.principal, err) } if _, err := agent.server.WaitNotification("turn/completed", timeout, before); err != nil { diff --git a/harness/cmd/mnemon-harness/acceptance_cluster_single_entrypoint.go b/harness/cmd/mnemon-acceptance/acceptance_cluster_single_entrypoint.go similarity index 99% rename from harness/cmd/mnemon-harness/acceptance_cluster_single_entrypoint.go rename to harness/cmd/mnemon-acceptance/acceptance_cluster_single_entrypoint.go index eab35e5f..4e0e22c3 100644 --- a/harness/cmd/mnemon-harness/acceptance_cluster_single_entrypoint.go +++ b/harness/cmd/mnemon-acceptance/acceptance_cluster_single_entrypoint.go @@ -74,7 +74,7 @@ func init() { acceptanceR1ClusterSingleEntrypointCmd.Flags().IntVar(&acceptanceClusterWakeCycles, "wake-cycles", 4, "generic worker wake cycles") acceptanceR1ClusterSingleEntrypointCmd.Flags().DurationVar(&acceptanceClusterWakeInterval, "wake-interval", 3*time.Second, "delay between worker wake cycles") acceptanceR1ClusterSingleEntrypointCmd.Flags().StringVar(&acceptanceClusterEntrypoint, "entrypoint", "", "explicit entrypoint principal; empty chooses by seed") - acceptanceCmd.AddCommand(acceptanceR1ClusterSingleEntrypointCmd) + rootCmd.AddCommand(acceptanceR1ClusterSingleEntrypointCmd) } type r1ClusterSingleEntrypointOptions struct { diff --git a/harness/cmd/mnemon-harness/acceptance_cluster_single_entrypoint_test.go b/harness/cmd/mnemon-acceptance/acceptance_cluster_single_entrypoint_test.go similarity index 100% rename from harness/cmd/mnemon-harness/acceptance_cluster_single_entrypoint_test.go rename to harness/cmd/mnemon-acceptance/acceptance_cluster_single_entrypoint_test.go diff --git a/harness/cmd/mnemon-harness/acceptance_github_mesh.go b/harness/cmd/mnemon-acceptance/acceptance_github_mesh.go similarity index 99% rename from harness/cmd/mnemon-harness/acceptance_github_mesh.go rename to harness/cmd/mnemon-acceptance/acceptance_github_mesh.go index 4166f0fb..16b54716 100644 --- a/harness/cmd/mnemon-harness/acceptance_github_mesh.go +++ b/harness/cmd/mnemon-acceptance/acceptance_github_mesh.go @@ -81,7 +81,7 @@ func init() { acceptanceR1GitHubMeshCmd.Flags().StringVar(&acceptanceGitHubBranchPrefix, "github-branch-prefix", "", "GitHub publication branch prefix; empty uses a run-scoped mnemond id prefix") acceptanceR1GitHubMeshCmd.Flags().StringArrayVar(&acceptanceGitHubScenarios, "scenario", nil, "natural scenario to run; repeatable") acceptanceR1GitHubMeshCmd.Flags().DurationVar(&acceptanceGitHubSyncInterval, "sync-interval", 30*time.Second, "GitHub sync interval per local mnemond") - acceptanceCmd.AddCommand(acceptanceR1GitHubMeshCmd) + rootCmd.AddCommand(acceptanceR1GitHubMeshCmd) } type r1GitHubMeshAcceptanceOptions struct { @@ -1162,6 +1162,7 @@ func setupR1CodexGitHubMeshAgents(ctx context.Context, runRoot, binDir, repo, to Host: "codex", ControlURL: localURL, Principal: principal, + HarnessBin: filepath.Join(binDir, "mnemon-harness"), ProjectRoot: workspace, UseToken: true, }); err != nil { diff --git a/harness/cmd/mnemon-harness/acceptance_github_mesh_test.go b/harness/cmd/mnemon-acceptance/acceptance_github_mesh_test.go similarity index 100% rename from harness/cmd/mnemon-harness/acceptance_github_mesh_test.go rename to harness/cmd/mnemon-acceptance/acceptance_github_mesh_test.go diff --git a/harness/cmd/mnemon-acceptance/acceptance_managed_runtime.go b/harness/cmd/mnemon-acceptance/acceptance_managed_runtime.go new file mode 100644 index 00000000..b584cda3 --- /dev/null +++ b/harness/cmd/mnemon-acceptance/acceptance_managed_runtime.go @@ -0,0 +1,635 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "github.com/mnemon-dev/mnemon/harness/internal/codexapp" + "github.com/mnemon-dev/mnemon/harness/internal/driver" + eventmodel "github.com/mnemon-dev/mnemon/harness/internal/event" + "github.com/mnemon-dev/mnemon/harness/internal/mnemond/presentation" + "github.com/spf13/cobra" +) + +var ( + acceptanceManagedExchange string + acceptanceManagedMnemondBin string + acceptanceManagedRuntimeAdapter string + acceptanceManagedGitHubRepo string + acceptanceManagedGitHubTokenFile string +) + +var acceptanceManagedRuntimeCmd = &cobra.Command{ + Use: "managed-runtime", + Short: "Run managed-runtime seed-and-observe acceptance", + RunE: func(cmd *cobra.Command, args []string) error { + report, err := runManagedRuntimeAcceptance(cmd.Context(), managedRuntimeAcceptanceOptions{ + RunRoot: acceptanceRunRoot, + Command: acceptanceCommand, + CodexHome: acceptanceCodexHome, + Agents: acceptanceAgents, + AgentTurns: acceptanceAgentTurns, + Exchange: acceptanceManagedExchange, + MnemondBin: acceptanceManagedMnemondBin, + Runtime: acceptanceManagedRuntimeAdapter, + GitHubRepo: acceptanceManagedGitHubRepo, + GitHubTokenFile: acceptanceManagedGitHubTokenFile, + TurnTimeout: acceptanceTurnTimeout, + Stdout: cmd.OutOrStdout(), + Stderr: cmd.ErrOrStderr(), + }) + if report.ReportPath != "" { + fmt.Fprintf(cmd.OutOrStdout(), "acceptance report: %s\n", report.ReportPath) + } + if err != nil { + return err + } + if report.Status != "ok" { + return fmt.Errorf("managed-runtime acceptance status: %s", report.Status) + } + return nil + }, +} + +func init() { + acceptanceManagedRuntimeCmd.Flags().StringVar(&acceptanceRunRoot, "run-root", "", "acceptance run directory") + acceptanceManagedRuntimeCmd.Flags().StringVar(&acceptanceCommand, "command", "codex --dangerously-bypass-hook-trust", "Codex CLI command") + acceptanceManagedRuntimeCmd.Flags().StringVar(&acceptanceCodexHome, "codex-home-source", "", "source CODEX_HOME to copy auth/config from") + acceptanceManagedRuntimeCmd.Flags().IntVar(&acceptanceAgents, "agents", 5, "number of managed agent nodes") + acceptanceManagedRuntimeCmd.Flags().BoolVar(&acceptanceAgentTurns, "agent-turns", false, "run real managed Codex turns after the seed") + acceptanceManagedRuntimeCmd.Flags().StringVar(&acceptanceManagedExchange, "exchange", "mnemonhub", "exchange mode: mnemonhub or github") + acceptanceManagedRuntimeCmd.Flags().StringVar(&acceptanceManagedMnemondBin, "mnemond-bin", "mnemond", "mnemond binary used for product-path wake checks") + acceptanceManagedRuntimeCmd.Flags().StringVar(&acceptanceManagedRuntimeAdapter, "runtime", "codex-appserver", "managed runtime adapter: codex-appserver or noop") + acceptanceManagedRuntimeCmd.Flags().StringVar(&acceptanceManagedGitHubRepo, "github-repo", "mnemon-dev/mnemon-teamwork-example", "GitHub Remote Workspace repository (owner/name)") + acceptanceManagedRuntimeCmd.Flags().StringVar(&acceptanceManagedGitHubTokenFile, "github-token-file", "", "GitHub token file for real GitHub exchange validation") + acceptanceManagedRuntimeCmd.Flags().DurationVar(&acceptanceTurnTimeout, "turn-timeout", 5*time.Minute, "timeout per managed wake check") + rootCmd.AddCommand(acceptanceManagedRuntimeCmd) +} + +type managedRuntimeAcceptanceOptions struct { + RunRoot string + Command string + CodexHome string + Agents int + AgentTurns bool + Exchange string + MnemondBin string + Runtime string + GitHubRepo string + GitHubTokenFile string + TurnTimeout time.Duration + Stdout io.Writer + Stderr io.Writer + Wake managedRuntimeWakeFunc +} + +type managedRuntimeWakeFunc func(context.Context, managedRuntimeAcceptanceOptions, string) (string, error) + +type managedRuntimeAcceptanceReport struct { + SchemaVersion int `json:"schema_version"` + Status string `json:"status"` + Layer string `json:"layer"` + RunnerRole string `json:"runner_role"` + Exchange string `json:"exchange"` + GitHubRepo string `json:"github_repo,omitempty"` + Runtime string `json:"runtime"` + StartedAt string `json:"started_at"` + FinishedAt string `json:"finished_at"` + RunRoot string `json:"run_root"` + ReportPath string `json:"report_path"` + Topology *r1AcceptanceTopologyReport `json:"topology,omitempty"` + Agents []managedRuntimeAgentReport `json:"agents"` + DriverWakes []driver.ManagedWakeRecord `json:"driver_wakes,omitempty"` + Scenarios []r1TaskSimScenarioReport `json:"scenarios,omitempty"` + LedgerCounts map[string]int `json:"ledger_counts,omitempty"` + Artifacts map[string]string `json:"artifacts,omitempty"` + Assertions []managedRuntimeAssertion `json:"assertions"` + PromptAudit []managedRuntimePromptAuditEntry `json:"prompt_audit"` + Errors []string `json:"errors,omitempty"` +} + +type managedRuntimeAgentReport struct { + Principal string `json:"principal"` + Workspace string `json:"workspace,omitempty"` + ThreadID string `json:"thread_id,omitempty"` + RawQuery string `json:"raw_query"` + Status string `json:"status"` +} + +type managedRuntimePromptAuditEntry struct { + Principal string `json:"principal"` + Kind string `json:"kind"` + Query string `json:"query"` +} + +type managedRuntimeAssertion struct { + Name string `json:"name"` + Passed bool `json:"passed"` + Detail string `json:"detail,omitempty"` +} + +func runManagedRuntimeAcceptance(ctx context.Context, opts managedRuntimeAcceptanceOptions) (managedRuntimeAcceptanceReport, error) { + started := time.Now().UTC() + if opts.Stdout == nil { + opts.Stdout = io.Discard + } + if opts.Stderr == nil { + opts.Stderr = io.Discard + } + if opts.Agents <= 0 { + opts.Agents = 5 + } + if opts.Command == "" { + opts.Command = "codex --dangerously-bypass-hook-trust" + } + if opts.TurnTimeout <= 0 { + opts.TurnTimeout = 5 * time.Minute + } + if strings.TrimSpace(opts.GitHubRepo) == "" { + opts.GitHubRepo = "mnemon-dev/mnemon-teamwork-example" + } + exchangeMode := strings.TrimSpace(opts.Exchange) + if exchangeMode == "" { + exchangeMode = "mnemonhub" + } + runtimeName := strings.TrimSpace(opts.Runtime) + if runtimeName == "" { + runtimeName = "codex-appserver" + } + runRoot := opts.RunRoot + if runRoot == "" { + runRoot = filepath.Join(".testdata", "managed-runtime", started.Format("20060102T150405Z")) + } + absRunRoot, err := filepath.Abs(runRoot) + if err != nil { + return managedRuntimeAcceptanceReport{}, err + } + runRoot = absRunRoot + report := managedRuntimeAcceptanceReport{ + SchemaVersion: 1, + Status: "ok", + Layer: "managed_runtime_acceptance", + RunnerRole: "seed_and_observe", + Exchange: exchangeMode, + GitHubRepo: strings.TrimSpace(opts.GitHubRepo), + Runtime: runtimeName, + StartedAt: started.Format(time.RFC3339), + RunRoot: runRoot, + Artifacts: map[string]string{}, + } + if err := os.MkdirAll(runRoot, 0o755); err != nil { + report.Status = "blocked" + report.Errors = append(report.Errors, err.Error()) + return finishManagedRuntimeReport(report), err + } + if exchangeMode != "mnemonhub" && exchangeMode != "github" { + err := fmt.Errorf("managed-runtime acceptance exchange must be mnemonhub or github, got %q", exchangeMode) + report.Status = "blocked" + report.Errors = append(report.Errors, err.Error()) + written, writeErr := finishAndWriteManagedRuntimeReport(report) + if writeErr != nil { + return written, writeErr + } + return written, err + } + if err := prepareR1AcceptanceRunRoot(runRoot); err != nil { + report.Status = "blocked" + report.Errors = append(report.Errors, err.Error()) + return finishManagedRuntimeReport(report), err + } + if exchangeMode == "github" && strings.TrimSpace(opts.GitHubTokenFile) == "" { + err := fmt.Errorf("managed-runtime github acceptance requires --github-token-file for real GitHub access") + report.Status = "blocked" + report.Errors = append(report.Errors, err.Error()) + addManagedAssertion(&report, "github token file provided", false, err.Error()) + written, writeErr := finishAndWriteManagedRuntimeReport(report) + if writeErr != nil { + return written, writeErr + } + return written, err + } + if opts.Wake != nil { + return runManagedRuntimeInjectedWakeAcceptance(ctx, opts, report) + } + if !opts.AgentTurns { + err := fmt.Errorf("managed-runtime acceptance requires --agent-turns for real seed-and-observe validation") + report.Status = "failed" + report.Errors = append(report.Errors, err.Error()) + addManagedAssertion(&report, "managed-runtime real agent turns requested", false, "rerun with --agent-turns") + written, writeErr := finishAndWriteManagedRuntimeReport(report) + if writeErr != nil { + return written, writeErr + } + return written, err + } + switch exchangeMode { + case "mnemonhub": + return runManagedRuntimeMnemonhubAcceptance(ctx, opts, report) + case "github": + return runManagedRuntimeGitHubAcceptance(ctx, opts, report) + default: + panic("validated exchange mode escaped switch") + } +} + +func runManagedRuntimeInjectedWakeAcceptance(ctx context.Context, opts managedRuntimeAcceptanceOptions, report managedRuntimeAcceptanceReport) (managedRuntimeAcceptanceReport, error) { + allSentinel := true + for i := 1; i <= opts.Agents; i++ { + principal := fmt.Sprintf("codex-%02d@project", i) + query, err := opts.Wake(ctx, opts, principal) + status := "ok" + if err != nil { + status = "failed" + allSentinel = false + report.Errors = append(report.Errors, fmt.Sprintf("%s: %v", principal, err)) + } + if strings.TrimSpace(query) != driver.ManagedWakeQuery { + allSentinel = false + } + report.Agents = append(report.Agents, managedRuntimeAgentReport{Principal: principal, RawQuery: strings.TrimSpace(query), Status: status}) + report.PromptAudit = append(report.PromptAudit, managedRuntimePromptAuditEntry{Principal: principal, Kind: "raw_managed_wake", Query: strings.TrimSpace(query)}) + } + addManagedAssertion(&report, "raw managed queries are sentinel only", allSentinel, fmt.Sprintf("agents=%d", len(report.Agents))) + addManagedAssertion(&report, "runner role is seed-and-observe", report.RunnerRole == "seed_and_observe", report.RunnerRole) + addManagedAssertion(&report, "no direct worker business prompts", managedRuntimeDirectWorkerPromptCount(report) == 0, "prompt_audit contains raw_managed_wake only") + if len(report.Errors) > 0 || !managedRuntimeAssertionsPassed(report) { + report.Status = "failed" + } + return finishAndWriteManagedRuntimeReport(report) +} + +func runManagedRuntimeMnemonhubAcceptance(ctx context.Context, opts managedRuntimeAcceptanceOptions, report managedRuntimeAcceptanceReport) (managedRuntimeAcceptanceReport, error) { + binDir, err := installAcceptanceHarnessBinary(report.RunRoot) + if err != nil { + return managedRuntimeBlocked(report, err) + } + hub, err := startR1SyncHub(report.RunRoot, opts.Agents) + if err != nil { + return managedRuntimeBlocked(report, err) + } + defer hub.close() + sourceCodexHome := resolveSourceCodexHome(opts.CodexHome) + report.Artifacts["codex_home_source"] = sourceCodexHome + report.Artifacts["hub_db"] = filepath.Join(report.RunRoot, "hub", "hub.db") + report.Artifacts["hub_audit"] = hub.AuditPath + agents, err := setupR1CodexSyncAgents(ctx, report.RunRoot, binDir, hub, opts.Agents, sourceCodexHome) + if err != nil { + return managedRuntimeBlocked(report, err) + } + defer stopR1CodexSyncAgents(agents) + report.Topology = buildR1ProdSimTopology(agents) + addManagedAssertion(&report, "managed-runtime strict per-hostagent mnemond topology", prodSimStrictTopology(report.Topology), fmt.Sprintf("%+v", report.Topology)) + for _, agent := range agents { + report.Artifacts["mnemond:"+agent.principal] = prodSimMnemondPath(agent) + report.Artifacts["render_audit:"+agent.principal] = agent.renderAuditPath + } + return runManagedRuntimeRealScenario(ctx, opts, report, agents) +} + +func runManagedRuntimeGitHubAcceptance(ctx context.Context, opts managedRuntimeAcceptanceOptions, report managedRuntimeAcceptanceReport) (managedRuntimeAcceptanceReport, error) { + tokenFile, err := filepath.Abs(opts.GitHubTokenFile) + if err != nil { + return managedRuntimeBlocked(report, err) + } + if _, err := os.Stat(tokenFile); err != nil { + return managedRuntimeBlocked(report, fmt.Errorf("github token file: %w", err)) + } + binDir, err := installAcceptanceHarnessBinary(report.RunRoot) + if err != nil { + return managedRuntimeBlocked(report, err) + } + started, _ := time.Parse(time.RFC3339, report.StartedAt) + branchPrefix := r1GitHubMeshBranchPrefix("", started) + branches := r1GitHubMeshBranches(branchPrefix, opts.Agents) + if err := ensureR1GitHubMeshBranches(ctx, report.GitHubRepo, tokenFile, branches); err != nil { + return managedRuntimeBlocked(report, err) + } + sourceCodexHome := resolveSourceCodexHome(opts.CodexHome) + report.Artifacts["codex_home_source"] = sourceCodexHome + report.Artifacts["github_repo"] = report.GitHubRepo + report.Artifacts["github_token_file"] = tokenFile + report.Artifacts["github_branch_prefix"] = branchPrefix + agents, err := setupR1CodexGitHubMeshAgents(ctx, report.RunRoot, binDir, report.GitHubRepo, tokenFile, branchPrefix, opts.Agents, sourceCodexHome, 30*time.Second, opts.Agents) + if err != nil { + return managedRuntimeBlocked(report, err) + } + defer stopR1CodexSyncAgents(agents) + report.Topology = buildR1ProdSimTopology(agents) + report.Topology.MnemonhubInstances = 0 + addManagedAssertion(&report, "managed-runtime github per-hostagent mnemond topology", r1GitHubMeshStrictTopology(report.Topology), fmt.Sprintf("%+v", report.Topology)) + addManagedAssertion(&report, "managed-runtime github has no central hub", report.Topology.MnemonhubInstances == 0, "backend=github") + for _, agent := range agents { + report.Artifacts["mnemond:"+agent.principal] = prodSimMnemondPath(agent) + report.Artifacts["render_audit:"+agent.principal] = agent.renderAuditPath + } + return runManagedRuntimeRealScenario(ctx, opts, report, agents) +} + +func runManagedRuntimeRealScenario(ctx context.Context, opts managedRuntimeAcceptanceOptions, report managedRuntimeAcceptanceReport, agents []r1CodexSyncAgent) (managedRuntimeAcceptanceReport, error) { + if len(agents) < 5 { + return managedRuntimeFailed(report, fmt.Errorf("managed-runtime requires at least 5 agents, got %d", len(agents))) + } + for i := range agents { + if err := startR1CodexAppserver(&agents[i].r1CodexAgent, opts.Command); err != nil { + return managedRuntimeBlocked(report, err) + } + agentReport, _, err := initializeManagedRuntimeCodexAgent(&agents[i].r1CodexAgent) + if err != nil { + return managedRuntimeBlocked(report, err) + } + report.Agents = append(report.Agents, managedRuntimeAgentReport{ + Principal: agentReport.Principal, + Workspace: agentReport.Workspace, + ThreadID: agentReport.ThreadID, + Status: "initialized", + }) + } + addManagedAssertion(&report, "managed-runtime 5/5 appservers start/init", len(report.Agents) == opts.Agents, fmt.Sprintf("started=%d requested=%d", len(report.Agents), opts.Agents)) + + runID := strings.ToLower(time.Now().UTC().Format("150405")) + entry := &agents[0] + worker := &agents[1] + assignmentID := "managed-asg-" + runID + signalID := "managed-signal-" + runID + seedPrompt := fmt.Sprintf(`You are the entry agent for a managed-runtime Mnemon acceptance run. +Emit one teamwork_signal.write_candidate.observed event with external id seed-signal-%s and payload: +{"rule":{"signal_id":%q,"scope":"managed-runtime/seeded-teamwork","ttl":"30m"},"narrative":{"statement":"Validate that managed local mnemond drivers can continue teamwork after one seed prompt.","why_teamwork":"a worker should act from hook-rendered Mnemon context after receiving only the managed wake sentinel"},"refs":{"evidence_refs":["managed-runtime acceptance seed"]}} +Then emit one assignment.write_candidate.observed event with external id seed-assignment-%s and payload: +{"rule":{"assignment_id":%q,"signal_ref":%q,"assignee":%q,"scope":"managed-runtime/seeded-teamwork","ttl":"20m"},"narrative":{"expected_work":"Inspect the hook-rendered managed work brief and report whether the sentinel-driven turn can advance the teamwork task.","expected_feedback":"progress_digest with evidence from the managed wake turn"},"refs":{"evidence_refs":["seed signal %s"]}} +Do not contact the worker directly. After both commands succeed, answer "seed written".`, runID, signalID, runID, assignmentID, signalID, worker.principal, signalID) + report.PromptAudit = append(report.PromptAudit, managedRuntimePromptAuditEntry{Principal: entry.principal, Kind: "entry_seed", Query: seedPrompt}) + answer, err := runR1Turn(&entry.r1CodexAgent, seedPrompt, opts.TurnTimeout) + _ = answer + if err != nil { + addManagedAssertion(&report, "managed-runtime entry seed accepted", false, err.Error()) + return managedRuntimeFailed(report, err) + } + waitForLedgerCount(entry.localURL, entry.r1CodexAgent, "assignment", 1, 30*time.Second) + entryCounts := countR1Ledger(entry.localURL, entry.r1CodexAgent) + addManagedAssertion(&report, "managed-runtime entry seed accepted", entryCounts["teamwork_signal"] >= 1 && entryCounts["assignment"] >= 1, fmt.Sprintf("teamwork_signal=%d assignment=%d", entryCounts["teamwork_signal"], entryCounts["assignment"])) + + workerRecord, err := managedRuntimeWakeExistingAgent(ctx, opts, worker, []string{assignmentID}) + if err != nil { + addManagedAssertion(&report, "managed-runtime worker woke from local mnemond driver", false, err.Error()) + return managedRuntimeFailed(report, err) + } + report.DriverWakes = append(report.DriverWakes, workerRecord) + report.PromptAudit = append(report.PromptAudit, managedRuntimePromptAuditEntry{Principal: worker.principal, Kind: "raw_managed_wake", Query: workerRecord.Query}) + addManagedAssertion(&report, "managed-runtime worker woke from local mnemond driver", workerRecord.Query == driver.ManagedWakeQuery && workerRecord.RenderAuditID != "", fmt.Sprintf("query=%q render_audit=%s", workerRecord.Query, workerRecord.RenderAuditID)) + waitForLedgerCount(worker.localURL, worker.r1CodexAgent, "progress_digest", 1, 60*time.Second) + workerCounts := countR1Ledger(worker.localURL, worker.r1CodexAgent) + addManagedAssertion(&report, "managed-runtime worker emitted progress through normal events", workerCounts["progress_digest"] >= 1, fmt.Sprintf("progress_digest=%d", workerCounts["progress_digest"])) + + leadRecord, err := managedRuntimeWakeExistingAgent(ctx, opts, entry, []string{assignmentID}) + if err != nil { + addManagedAssertion(&report, "managed-runtime lead woke for integrate", false, err.Error()) + return managedRuntimeFailed(report, err) + } + report.DriverWakes = append(report.DriverWakes, leadRecord) + report.PromptAudit = append(report.PromptAudit, managedRuntimePromptAuditEntry{Principal: entry.principal, Kind: "raw_managed_wake", Query: leadRecord.Query}) + addManagedAssertion(&report, "managed-runtime lead woke for integrate", leadRecord.Query == driver.ManagedWakeQuery && leadRecord.RenderAuditID != "", fmt.Sprintf("query=%q render_audit=%s", leadRecord.Query, leadRecord.RenderAuditID)) + + report.LedgerCounts = countR1Ledger(entry.localURL, entry.r1CodexAgent) + report.Scenarios = append(report.Scenarios, r1TaskSimScenarioReport{ + Name: "seeded_teamwork_managed_wake", + Status: statusFromBool(managedRuntimeAssertionsPassed(report)), + Actors: []string{entry.principal, worker.principal}, + Evidence: map[string]any{ + "assignment": assignmentID, + "driver_wakes": len(report.DriverWakes), + "raw_query": driver.ManagedWakeQuery, + }, + }) + addManagedAssertion(&report, "raw managed queries are sentinel only", managedRuntimeRawQueriesSentinel(report), fmt.Sprintf("wakes=%d", len(report.DriverWakes))) + addManagedAssertion(&report, "runner role is seed-and-observe", report.RunnerRole == "seed_and_observe", report.RunnerRole) + addManagedAssertion(&report, "no direct worker business prompts", managedRuntimeDirectWorkerPromptCount(report) == 0, "only entry_seed and raw_managed_wake prompts recorded") + if len(report.Errors) == 0 && managedRuntimeAssertionsPassed(report) { + report.Status = "ok" + return finishAndWriteManagedRuntimeReport(report) + } + return managedRuntimeFailed(report, fmt.Errorf("managed-runtime acceptance failed")) +} + +func initializeManagedRuntimeCodexAgent(agent *r1CodexAgent) (r1CodexAgentReport, json.RawMessage, error) { + initResp, err := agent.server.Request("initialize", map[string]any{ + "clientInfo": map[string]any{"name": "mnemon-managed-runtime-acceptance", "version": version}, + "capabilities": codexAppServerCapabilities(), + }, 30*time.Second) + if err != nil { + return r1CodexAgentReport{}, nil, fmt.Errorf("%s: initialize: %w", agent.principal, err) + } + _ = initResp + hooksResp, err := agent.server.Request("hooks/list", map[string]any{"cwds": []string{agent.workspace}}, 30*time.Second) + if err != nil { + return r1CodexAgentReport{}, nil, fmt.Errorf("%s: hooks/list: %w", agent.principal, err) + } + hooksRaw, _ := json.Marshal(hooksResp) + hooks := collectHookMetadata(hooksResp) + thread, err := agent.server.Request("thread/start", map[string]any{ + "cwd": agent.workspace, + "approvalPolicy": "never", + "sandbox": "danger-full-access", + "ephemeral": true, + }, 30*time.Second) + if err != nil { + return r1CodexAgentReport{}, hooksRaw, fmt.Errorf("%s: thread/start: %w", agent.principal, err) + } + agent.threadID = codexapp.ThreadID(thread) + if agent.threadID == "" { + return r1CodexAgentReport{}, hooksRaw, fmt.Errorf("%s: thread/start returned no thread id", agent.principal) + } + rendered, err := runManualR1HookReminder(agent) + if err != nil { + return r1CodexAgentReport{}, hooksRaw, err + } + return r1CodexAgentReport{ + Principal: agent.principal, + Workspace: agent.workspace, + CodexHome: agent.codexHome, + ThreadID: agent.threadID, + HookCount: len(hooks), + HookTrustStatuses: hookTrustStatuses(hooks), + ManualHookReminded: strings.Contains(rendered, "governed context") || strings.Contains(rendered, "systemMessage"), + }, hooksRaw, nil +} + +type managedRuntimeExistingCodexClient struct { + agent *r1CodexAgent + timeout time.Duration +} + +func (c managedRuntimeExistingCodexClient) StartTurn(ctx context.Context, query string) (driver.ManagedTurnResult, error) { + if strings.TrimSpace(query) != driver.ManagedWakeQuery { + return driver.ManagedTurnResult{}, fmt.Errorf("managed acceptance client only accepts %q queries", driver.ManagedWakeQuery) + } + additionalContext, err := driver.CodexAppServerAdditionalContext(ctx, c.agent.workspace, c.agent.env, driver.ManagedWakeQuery) + if err != nil { + return driver.ManagedTurnResult{}, err + } + answer, err := runR1TurnWithAdditionalContext(c.agent, driver.ManagedWakeQuery, c.timeout, additionalContext) + status := "completed" + if err != nil { + status = "failed" + } + return driver.ManagedTurnResult{TurnID: c.agent.threadID, Status: status, FinalAnswer: answer}, err +} + +func managedRuntimeWakeExistingAgent(ctx context.Context, opts managedRuntimeAcceptanceOptions, agent *r1CodexSyncAgent, bodyWants []string) (driver.ManagedWakeRecord, error) { + resp, ok := waitForR1DerivedEventPresentation(agent.localURL, agent.token, bodyWants, 90*time.Second) + if !ok { + return driver.ManagedWakeRecord{}, fmt.Errorf("%s did not receive derived presentation containing %v", agent.principal, bodyWants) + } + candidate, ok := managedRuntimeCandidateFromRender(agent.principal, resp, bodyWants) + if !ok { + return driver.ManagedWakeRecord{}, fmt.Errorf("%s render had no wake candidate matching %v", agent.principal, bodyWants) + } + ledgerPath := filepath.Join(agent.workspace, ".mnemon", "harness", "local", "managed-agent", "wake-ledger.jsonl") + managed := &driver.ManagedAgentDriver{ + Principal: agent.principal, + Client: managedRuntimeExistingCodexClient{agent: &agent.r1CodexAgent, timeout: opts.TurnTimeout}, + Ledger: driver.NewFileManagedWakeLedger(ledgerPath), + Now: func() time.Time { return time.Now().UTC() }, + } + return managed.Wake(ctx, candidate) +} + +func managedRuntimeCandidateFromRender(principal string, resp presentation.Response, bodyWants []string) (driver.ManagedWakeCandidate, bool) { + for _, env := range resp.Events { + if string(env.Event.Audience) != principal { + continue + } + body, _ := eventmodel.PayloadNarrative(env.Event.Payload)["body"].(string) + if !managedRuntimeContainsAll(body, bodyWants) { + continue + } + candidates := driver.ManagedWakeCandidatesFromEvents(principal, []eventmodel.EventEnvelope{env}) + if len(candidates) == 0 { + continue + } + candidates[0].RenderAuditID = resp.AuditID + candidates[0].RenderBodyDigest = resp.BodyDigest + return candidates[0], true + } + return driver.ManagedWakeCandidate{}, false +} + +func managedRuntimeContainsAll(body string, wants []string) bool { + for _, want := range wants { + if strings.TrimSpace(want) != "" && !strings.Contains(body, want) { + return false + } + } + return true +} + +func managedRuntimeRawQueriesSentinel(report managedRuntimeAcceptanceReport) bool { + for _, entry := range report.PromptAudit { + if entry.Kind != "raw_managed_wake" { + continue + } + if strings.TrimSpace(entry.Query) != driver.ManagedWakeQuery { + return false + } + } + return true +} + +func managedRuntimeBlocked(report managedRuntimeAcceptanceReport, err error) (managedRuntimeAcceptanceReport, error) { + report.Status = "blocked" + report.Errors = append(report.Errors, err.Error()) + written, writeErr := finishAndWriteManagedRuntimeReport(report) + if writeErr != nil { + return written, writeErr + } + return written, err +} + +func managedRuntimeFailed(report managedRuntimeAcceptanceReport, err error) (managedRuntimeAcceptanceReport, error) { + report.Status = "failed" + if err != nil { + report.Errors = append(report.Errors, err.Error()) + } + written, writeErr := finishAndWriteManagedRuntimeReport(report) + if writeErr != nil { + return written, writeErr + } + return written, err +} + +func managedRuntimeMnemondDryRunWake(ctx context.Context, opts managedRuntimeAcceptanceOptions, principal string) (string, error) { + timeout := opts.TurnTimeout + if timeout <= 0 { + timeout = 30 * time.Second + } + wakeCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + bin := strings.TrimSpace(opts.MnemondBin) + if bin == "" { + bin = "mnemond" + } + cmd := exec.CommandContext(wakeCtx, bin, "agent", "run", "--principal", principal, "--dry-run") + out, err := cmd.CombinedOutput() + if err != nil { + return strings.TrimSpace(string(out)), fmt.Errorf("mnemond managed wake dry-run: %w: %s", err, strings.TrimSpace(string(out))) + } + return strings.TrimSpace(string(out)), nil +} + +func managedRuntimeDirectWorkerPromptCount(report managedRuntimeAcceptanceReport) int { + count := 0 + for _, entry := range report.PromptAudit { + if entry.Kind == "worker_business" { + count++ + } + } + return count +} + +func addManagedAssertion(report *managedRuntimeAcceptanceReport, name string, passed bool, detail string) { + report.Assertions = append(report.Assertions, managedRuntimeAssertion{Name: name, Passed: passed, Detail: detail}) +} + +func managedRuntimeAssertionsPassed(report managedRuntimeAcceptanceReport) bool { + for _, assertion := range report.Assertions { + if !assertion.Passed { + return false + } + } + return true +} + +func finishAndWriteManagedRuntimeReport(report managedRuntimeAcceptanceReport) (managedRuntimeAcceptanceReport, error) { + report = finishManagedRuntimeReport(report) + path := filepath.Join(report.RunRoot, "acceptance-report.json") + raw, err := json.MarshalIndent(report, "", " ") + if err != nil { + return report, err + } + if err := os.WriteFile(path, append(raw, '\n'), 0o644); err != nil { + return report, err + } + report.ReportPath = path + raw, err = json.MarshalIndent(report, "", " ") + if err != nil { + return report, err + } + if err := os.WriteFile(path, append(raw, '\n'), 0o644); err != nil { + return report, err + } + return report, nil +} + +func finishManagedRuntimeReport(report managedRuntimeAcceptanceReport) managedRuntimeAcceptanceReport { + report.FinishedAt = time.Now().UTC().Format(time.RFC3339) + if report.Status == "" { + report.Status = "ok" + } + return report +} diff --git a/harness/cmd/mnemon-acceptance/acceptance_managed_runtime_test.go b/harness/cmd/mnemon-acceptance/acceptance_managed_runtime_test.go new file mode 100644 index 00000000..d7688f45 --- /dev/null +++ b/harness/cmd/mnemon-acceptance/acceptance_managed_runtime_test.go @@ -0,0 +1,95 @@ +package main + +import ( + "context" + "os" + "strings" + "testing" + + "github.com/mnemon-dev/mnemon/harness/internal/driver" +) + +func TestManagedRuntimeAcceptanceCommandRegistered(t *testing.T) { + commands := map[string]bool{} + for _, cmd := range rootCmd.Commands() { + commands[cmd.Name()] = true + } + if !commands["managed-runtime"] { + t.Fatalf("mnemon-acceptance should expose managed-runtime command: %v", commands) + } +} + +func TestManagedRuntimeAcceptanceRequiresSentinelWake(t *testing.T) { + report, err := runManagedRuntimeAcceptance(context.Background(), managedRuntimeAcceptanceOptions{ + RunRoot: t.TempDir(), + Agents: 3, + Exchange: "mnemonhub", + Wake: func(_ context.Context, _ managedRuntimeAcceptanceOptions, _ string) (string, error) { + return driver.ManagedWakeQuery, nil + }, + }) + if err != nil { + t.Fatal(err) + } + if report.Status != "ok" || report.Layer != "managed_runtime_acceptance" || report.RunnerRole != "seed_and_observe" { + t.Fatalf("report contract mismatch: %+v", report) + } + if len(report.Agents) != 3 { + t.Fatalf("agents = %d, want 3", len(report.Agents)) + } + for _, agent := range report.Agents { + if agent.RawQuery != driver.ManagedWakeQuery { + t.Fatalf("raw query = %q, want %q", agent.RawQuery, driver.ManagedWakeQuery) + } + } + if managedRuntimeDirectWorkerPromptCount(report) != 0 { + t.Fatalf("managed acceptance must not record worker business prompts: %+v", report.PromptAudit) + } +} + +func TestManagedRuntimeAcceptanceRejectsNonSentinelWake(t *testing.T) { + report, err := runManagedRuntimeAcceptance(context.Background(), managedRuntimeAcceptanceOptions{ + RunRoot: t.TempDir(), + Agents: 1, + Exchange: "mnemonhub", + Wake: func(_ context.Context, _ managedRuntimeAcceptanceOptions, _ string) (string, error) { + return "inspect assignment asg1", nil + }, + }) + if err != nil { + t.Fatal(err) + } + if report.Status == "ok" { + t.Fatalf("non-sentinel wake should fail managed acceptance: %+v", report) + } + if !strings.Contains(report.Agents[0].RawQuery, "assignment") { + t.Fatalf("test setup did not preserve non-sentinel query: %+v", report.Agents) + } +} + +func TestManagedRuntimeGitHubRequiresTokenFile(t *testing.T) { + report, err := runManagedRuntimeAcceptance(context.Background(), managedRuntimeAcceptanceOptions{ + RunRoot: t.TempDir(), + Agents: 1, + Exchange: "github", + Wake: func(_ context.Context, _ managedRuntimeAcceptanceOptions, _ string) (string, error) { + return driver.ManagedWakeQuery, nil + }, + }) + if err == nil { + t.Fatal("github managed acceptance without token file should return an explicit blocker") + } + if report.Status != "blocked" || !strings.Contains(err.Error(), "--github-token-file") { + t.Fatalf("github blocker mismatch: status=%s err=%v report=%+v", report.Status, err, report) + } +} + +func TestManagedRuntimeAcceptanceDoesNotInjectDeveloperInstructions(t *testing.T) { + raw, err := os.ReadFile("acceptance_managed_runtime.go") + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(raw), "developerInstructions") { + t.Fatal("managed-runtime acceptance must rely on standard GUIDE/hook/render flow, not acceptance-specific developer instructions") + } +} diff --git a/harness/cmd/mnemon-harness/acceptance_observe.go b/harness/cmd/mnemon-acceptance/acceptance_observe.go similarity index 99% rename from harness/cmd/mnemon-harness/acceptance_observe.go rename to harness/cmd/mnemon-acceptance/acceptance_observe.go index 063c12a3..388bd8e1 100644 --- a/harness/cmd/mnemon-harness/acceptance_observe.go +++ b/harness/cmd/mnemon-acceptance/acceptance_observe.go @@ -75,7 +75,7 @@ func init() { acceptanceObserveCmd.Flags().BoolVar(&acceptanceObserveWatch, "watch", false, "continue refreshing observation snapshots") acceptanceObserveCmd.Flags().DurationVar(&acceptanceObserveInterval, "interval", 2*time.Second, "watch refresh interval") acceptanceObserveCmd.Flags().BoolVar(&acceptanceObserveOnce, "once", false, "render one watch snapshot and exit") - acceptanceCmd.AddCommand(acceptanceObserveCmd) + rootCmd.AddCommand(acceptanceObserveCmd) } type acceptanceObserveReport struct { diff --git a/harness/cmd/mnemon-harness/acceptance_observe_test.go b/harness/cmd/mnemon-acceptance/acceptance_observe_test.go similarity index 99% rename from harness/cmd/mnemon-harness/acceptance_observe_test.go rename to harness/cmd/mnemon-acceptance/acceptance_observe_test.go index ea47ff0e..f26eb8c1 100644 --- a/harness/cmd/mnemon-harness/acceptance_observe_test.go +++ b/harness/cmd/mnemon-acceptance/acceptance_observe_test.go @@ -57,7 +57,7 @@ func TestObserveAcceptanceRunReadsMnemondAndHubEvents(t *testing.T) { func TestAcceptanceObservationUsesSingleCommandSurface(t *testing.T) { commands := map[string]bool{} - for _, cmd := range acceptanceCmd.Commands() { + for _, cmd := range rootCmd.Commands() { commands[cmd.Name()] = true } if !commands["observe"] { diff --git a/harness/cmd/mnemon-harness/acceptance_prod_sim.go b/harness/cmd/mnemon-acceptance/acceptance_prod_sim.go similarity index 99% rename from harness/cmd/mnemon-harness/acceptance_prod_sim.go rename to harness/cmd/mnemon-acceptance/acceptance_prod_sim.go index c7f15407..5ae14315 100644 --- a/harness/cmd/mnemon-harness/acceptance_prod_sim.go +++ b/harness/cmd/mnemon-acceptance/acceptance_prod_sim.go @@ -51,7 +51,7 @@ func init() { acceptanceR1ProdSimCmd.Flags().IntVar(&acceptanceAgents, "agents", 5, "number of Codex appservers") acceptanceR1ProdSimCmd.Flags().BoolVar(&acceptanceAgentTurns, "agent-turns", false, "run real model turns that write governed R1 production-like events") acceptanceR1ProdSimCmd.Flags().DurationVar(&acceptanceTurnTimeout, "turn-timeout", 5*time.Minute, "timeout per real agent turn") - acceptanceCmd.AddCommand(acceptanceR1ProdSimCmd) + rootCmd.AddCommand(acceptanceR1ProdSimCmd) } type r1ProdSimAcceptanceOptions struct { diff --git a/harness/cmd/mnemon-harness/acceptance_prod_sim_test.go b/harness/cmd/mnemon-acceptance/acceptance_prod_sim_test.go similarity index 100% rename from harness/cmd/mnemon-harness/acceptance_prod_sim_test.go rename to harness/cmd/mnemon-acceptance/acceptance_prod_sim_test.go diff --git a/harness/cmd/mnemon-harness/acceptance_task_sim.go b/harness/cmd/mnemon-acceptance/acceptance_task_sim.go similarity index 99% rename from harness/cmd/mnemon-harness/acceptance_task_sim.go rename to harness/cmd/mnemon-acceptance/acceptance_task_sim.go index 6aea9b19..8e861ad1 100644 --- a/harness/cmd/mnemon-harness/acceptance_task_sim.go +++ b/harness/cmd/mnemon-acceptance/acceptance_task_sim.go @@ -57,7 +57,7 @@ func init() { acceptanceR1TaskSimCmd.Flags().BoolVar(&acceptanceSyncArm, "sync-arm", false, "run the cross-workspace sync/import scenario") acceptanceR1TaskSimCmd.Flags().DurationVar(&acceptanceTurnTimeout, "turn-timeout", 5*time.Minute, "timeout per real agent turn") acceptanceR1TaskSimCmd.Flags().StringArrayVar(&acceptanceTaskSimScenarios, "scenario", nil, "scenario to run; repeatable") - acceptanceCmd.AddCommand(acceptanceR1TaskSimCmd) + rootCmd.AddCommand(acceptanceR1TaskSimCmd) } type r1TaskSimAcceptanceOptions struct { diff --git a/harness/cmd/mnemon-harness/acceptance_test.go b/harness/cmd/mnemon-acceptance/acceptance_test.go similarity index 100% rename from harness/cmd/mnemon-harness/acceptance_test.go rename to harness/cmd/mnemon-acceptance/acceptance_test.go diff --git a/harness/cmd/mnemon-acceptance/root.go b/harness/cmd/mnemon-acceptance/root.go new file mode 100644 index 00000000..9728863b --- /dev/null +++ b/harness/cmd/mnemon-acceptance/root.go @@ -0,0 +1,26 @@ +package main + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" +) + +var version = "dev" + +var rootCmd = &cobra.Command{ + Use: "mnemon-acceptance", + Version: version, + Short: "Mnemon test-only acceptance runner", + CompletionOptions: cobra.CompletionOptions{DisableDefaultCmd: true}, + Long: "Run controlled Mnemon acceptance scenarios. This command is test-only: " + + "it starts, seeds, observes, and reports on product surfaces without defining runtime semantics.", +} + +func main() { + if err := rootCmd.Execute(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} diff --git a/harness/cmd/mnemon-acceptance/root_test.go b/harness/cmd/mnemon-acceptance/root_test.go new file mode 100644 index 00000000..9d28d61d --- /dev/null +++ b/harness/cmd/mnemon-acceptance/root_test.go @@ -0,0 +1,25 @@ +package main + +import "testing" + +func TestRootExposesAcceptanceScenarioCommands(t *testing.T) { + commands := map[string]bool{} + for _, cmd := range rootCmd.Commands() { + commands[cmd.Name()] = true + } + for _, want := range []string{ + "observe", + "r1-codex", + "r1-prod-sim", + "r1-task-sim", + "r1-cluster-single-entrypoint", + "r1-github-mesh-task-suite", + } { + if !commands[want] { + t.Fatalf("mnemon-acceptance missing command %q; commands=%v", want, commands) + } + } + if commands["acceptance"] { + t.Fatalf("mnemon-acceptance should expose scenarios directly, not under an acceptance parent") + } +} diff --git a/harness/cmd/mnemon-acceptance/sync_remote.go b/harness/cmd/mnemon-acceptance/sync_remote.go new file mode 100644 index 00000000..4a37723f --- /dev/null +++ b/harness/cmd/mnemon-acceptance/sync_remote.go @@ -0,0 +1,90 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/mnemon-dev/mnemon/harness/internal/mnemonhub/exchange" +) + +func upsertSyncRemote(path, root, id, backend, direction, endpoint, repo, branch, token, tokenFile, caFile string) error { + doc := exchange.RemotesDoc{SchemaVersion: 1} + if raw, err := os.ReadFile(path); err == nil && len(strings.TrimSpace(string(raw))) > 0 { + if err := json.Unmarshal(raw, &doc); err != nil { + return fmt.Errorf("parse Remote Workspace config: %w", err) + } + if doc.SchemaVersion != 1 { + return fmt.Errorf("Remote Workspace config schema_version %d unsupported (want 1)", doc.SchemaVersion) + } + } else if err != nil && !os.IsNotExist(err) { + return fmt.Errorf("read Remote Workspace config: %w", err) + } + credentialRef, err := syncCredentialRef(root, id, token, tokenFile) + if err != nil { + return err + } + entry := exchange.RemoteEntry{ + Backend: backend, + Direction: direction, + ID: id, + Endpoint: endpoint, + Repo: repo, + Branch: branch, + CredentialRef: credentialRef, + CAFile: normalizeSyncFileRef(caFile), + } + replaced := false + for i := range doc.Remotes { + if doc.Remotes[i].ID == id { + doc.Remotes[i] = entry + replaced = true + break + } + } + if !replaced { + doc.Remotes = append(doc.Remotes, entry) + } + doc.Current = id + data, err := json.MarshalIndent(doc, "", " ") + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return err + } + return os.WriteFile(path, append(data, '\n'), 0o644) +} + +func normalizeSyncFileRef(ref string) string { + ref = strings.TrimSpace(ref) + if ref == "" || filepath.IsAbs(ref) { + return ref + } + return filepath.ToSlash(filepath.Clean(ref)) +} + +func syncCredentialRef(root, id, token, tokenFile string) (string, error) { + token = strings.TrimSpace(token) + tokenFile = strings.TrimSpace(tokenFile) + if token != "" { + credentialRef := filepath.ToSlash(filepath.Join(".mnemon", "harness", "sync", "credentials", id+".token")) + path := filepath.Join(root, filepath.FromSlash(credentialRef)) + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return "", err + } + if err := os.WriteFile(path, []byte(token+"\n"), 0o600); err != nil { + return "", err + } + return credentialRef, nil + } + if tokenFile == "" { + return "", fmt.Errorf("--token or --token-file is required") + } + if filepath.IsAbs(tokenFile) { + return tokenFile, nil + } + return filepath.ToSlash(filepath.Clean(tokenFile)), nil +} diff --git a/harness/cmd/mnemon-harness/control.go b/harness/cmd/mnemon-harness/control.go index 284c77b4..721992c4 100644 --- a/harness/cmd/mnemon-harness/control.go +++ b/harness/cmd/mnemon-harness/control.go @@ -266,10 +266,10 @@ func init() { controlLeafCommands := []*cobra.Command{controlObserveCmd, controlPullCmd, controlStatusCmd, controlRenderCmd} controlLeafCommands = append(controlLeafCommands, controlShortObserveCommands()...) for _, c := range controlLeafCommands { - c.Flags().StringVar(&controlAddr, "addr", "http://127.0.0.1:8787", "server base URL") - c.Flags().StringVar(&controlPrincipal, "principal", "", "authenticated principal (trusted-header transport)") - c.Flags().StringVar(&controlToken, "token", "", "bearer token (TokenAuthenticator transport)") - c.Flags().StringVar(&controlTokenFile, "token-file", "", "read the bearer token from a file (keeps tokens out of prompt-visible command lines)") + c.Flags().StringVar(&controlAddr, "addr", envDefault("MNEMON_CONTROL_ADDR", "http://127.0.0.1:8787"), "server base URL") + c.Flags().StringVar(&controlPrincipal, "principal", envDefault("MNEMON_CONTROL_PRINCIPAL", ""), "authenticated principal (trusted-header transport)") + c.Flags().StringVar(&controlToken, "token", envDefault("MNEMON_CONTROL_TOKEN", ""), "bearer token (TokenAuthenticator transport)") + c.Flags().StringVar(&controlTokenFile, "token-file", envDefault("MNEMON_CONTROL_TOKEN_FILE", ""), "read the bearer token from a file (keeps tokens out of prompt-visible command lines)") } controlObserveCmd.Flags().StringVar(&controlType, "type", "", "observed event type") controlObserveCmd.Flags().StringVar(&controlPayload, "payload", "", "observation payload as JSON") @@ -286,3 +286,10 @@ func init() { controlCmd.GroupID = groupSpine rootCmd.AddCommand(controlCmd) } + +func envDefault(key, fallback string) string { + if value := strings.TrimSpace(os.Getenv(key)); value != "" { + return value + } + return fallback +} diff --git a/harness/cmd/mnemon-harness/root_test.go b/harness/cmd/mnemon-harness/root_test.go index 595bc7a7..d9d41da5 100644 --- a/harness/cmd/mnemon-harness/root_test.go +++ b/harness/cmd/mnemon-harness/root_test.go @@ -34,6 +34,18 @@ func TestRootHelpUsesLocalFirstProductSurface(t *testing.T) { } } +func TestRootDoesNotExposeAcceptanceCommands(t *testing.T) { + commands := map[string]bool{} + for _, cmd := range rootCmd.Commands() { + commands[cmd.Name()] = true + } + for _, blocked := range []string{"acceptance", "r1-codex", "r1-prod-sim", "r1-task-sim", "r1-github-mesh-task-suite"} { + if commands[blocked] { + t.Fatalf("mnemon-harness must not expose test-only acceptance command %q", blocked) + } + } +} + func TestProductHelpDoesNotExposeInternalVocabulary(t *testing.T) { for _, args := range [][]string{ {"setup", "--help"}, diff --git a/harness/cmd/mnemond/agent.go b/harness/cmd/mnemond/agent.go new file mode 100644 index 00000000..93375342 --- /dev/null +++ b/harness/cmd/mnemond/agent.go @@ -0,0 +1,187 @@ +package main + +import ( + "context" + "encoding/json" + "flag" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "time" + + "github.com/mnemon-dev/mnemon/harness/internal/contract" + "github.com/mnemon-dev/mnemon/harness/internal/driver" + "github.com/mnemon-dev/mnemon/harness/internal/mnemond/presentation" +) + +func runAgent(ctx context.Context, args []string, out, errw io.Writer) error { + if len(args) == 0 { + return fmt.Errorf("agent requires a subcommand") + } + switch args[0] { + case "run": + return runAgentRun(ctx, args[1:], out, errw) + default: + return fmt.Errorf("unknown agent subcommand %q", args[0]) + } +} + +func runAgentRun(ctx context.Context, args []string, out, errw io.Writer) error { + fs := flag.NewFlagSet("mnemond agent run", flag.ContinueOnError) + fs.SetOutput(errw) + principal := fs.String("principal", "", "local managed agent principal") + runtimeName := fs.String("runtime", "noop", "managed runtime adapter (noop or codex-appserver)") + dryRun := fs.Bool("dry-run", false, "print the managed wake query without starting a runtime turn") + cooldown := fs.Duration("cooldown", 0, "minimum delay between managed wakes") + addr := fs.String("addr", "", "local mnemond HTTP address used to render wake candidates") + tokenFile := fs.String("token-file", "", "file containing the local mnemond bearer token") + workspace := fs.String("workspace", "", "managed agent workspace") + command := fs.String("command", "codex", "Codex CLI command for --runtime codex-appserver") + codexHome := fs.String("codex-home", "", "CODEX_HOME for --runtime codex-appserver") + ledgerPath := fs.String("ledger", "", "managed wake ledger path") + lifecycle := fs.String("lifecycle", "remind", "render lifecycle used for wake candidate derivation") + renderIntent := fs.String("render-intent", presentation.IntentTeamworkEvents, "render intent used for wake candidate derivation") + surface := fs.String("surface", "hook", "render surface used for wake candidate derivation") + turnTimeout := fs.Duration("turn-timeout", 5*time.Minute, "timeout for one managed agent turn") + if err := fs.Parse(args); err != nil { + return err + } + localPrincipal := strings.TrimSpace(*principal) + if localPrincipal == "" { + return fmt.Errorf("mnemond agent run requires --principal") + } + if *dryRun { + fmt.Fprintln(out, driver.ManagedWakeQuery) + return nil + } + candidate := driver.ManagedWakeCandidate{ + Principal: localPrincipal, + DerivedEventID: "manual:agent-run", + BodyDigest: "sha256:manual", + Reason: "manual", + } + if strings.TrimSpace(*addr) != "" { + token, err := readAgentTokenFile(*tokenFile) + if err != nil { + return err + } + resp, err := (driver.HTTPRenderClient{ + BaseURL: strings.TrimSpace(*addr), + Token: token, + Principal: contract.ActorID(localPrincipal), + }).Render(ctx, presentation.Request{ + SchemaVersion: 1, + Principal: contract.ActorID(localPrincipal), + RenderIntent: strings.TrimSpace(*renderIntent), + Lifecycle: strings.TrimSpace(*lifecycle), + Surface: strings.TrimSpace(*surface), + }) + if err != nil { + return err + } + candidates := driver.ManagedWakeCandidatesFromRender(localPrincipal, resp) + if len(candidates) == 0 { + return writeManagedAgentRecord(out, driver.ManagedWakeRecord{ + Principal: localPrincipal, + Status: "skipped", + Reason: "no_wake_candidate", + StartedAt: time.Now().UTC(), + RenderAuditID: resp.AuditID, + RenderBodyDigest: resp.BodyDigest, + }) + } + candidate = candidates[0] + } + client, err := managedTurnClient(localPrincipal, *runtimeName, *command, *workspace, *codexHome, *turnTimeout) + if err != nil { + return err + } + ledger := managedWakeLedger(*ledgerPath, *workspace) + managed := &driver.ManagedAgentDriver{ + Principal: localPrincipal, + Client: client, + Ledger: ledger, + Cooldown: *cooldown, + Now: func() time.Time { return time.Now().UTC() }, + } + record, err := managed.Wake(ctx, candidate) + if err != nil { + return err + } + return writeManagedAgentRecord(out, record) +} + +func managedTurnClient(principal, runtimeName, command, workspace, codexHome string, turnTimeout time.Duration) (driver.ManagedTurnClient, error) { + switch strings.TrimSpace(runtimeName) { + case "", "noop": + return noopManagedTurnClient{}, nil + case "codex-appserver": + env := os.Environ() + if strings.TrimSpace(codexHome) != "" { + env = setAgentEnv(env, "CODEX_HOME", strings.TrimSpace(codexHome)) + } + return driver.CodexAppServerTurnClient{ + Principal: strings.TrimSpace(principal), + Command: strings.TrimSpace(command), + Workspace: strings.TrimSpace(workspace), + Env: env, + TurnTimeout: turnTimeout, + }, nil + default: + return nil, fmt.Errorf("runtime %q is not supported; use noop or codex-appserver", runtimeName) + } +} + +func managedWakeLedger(explicitPath, workspace string) driver.ManagedWakeLedger { + path := strings.TrimSpace(explicitPath) + if path == "" { + root := strings.TrimSpace(workspace) + if root == "" { + root = "." + } + path = filepath.Join(root, ".mnemon", "harness", "local", "managed-agent", "wake-ledger.jsonl") + } + return driver.NewFileManagedWakeLedger(path) +} + +func readAgentTokenFile(path string) (string, error) { + if strings.TrimSpace(path) == "" { + return "", nil + } + data, err := os.ReadFile(path) + if err != nil { + return "", err + } + return strings.TrimSpace(string(data)), nil +} + +func setAgentEnv(env []string, key, value string) []string { + prefix := key + "=" + out := env[:0] + for _, item := range env { + if !strings.HasPrefix(item, prefix) { + out = append(out, item) + } + } + return append(out, prefix+value) +} + +func writeManagedAgentRecord(out io.Writer, record driver.ManagedWakeRecord) error { + raw, err := json.MarshalIndent(record, "", " ") + if err != nil { + return err + } + fmt.Fprintln(out, string(raw)) + return nil +} + +type noopManagedTurnClient struct{} + +func (noopManagedTurnClient) StartTurn(_ context.Context, query string) (driver.ManagedTurnResult, error) { + if query != driver.ManagedWakeQuery { + return driver.ManagedTurnResult{}, fmt.Errorf("unexpected managed wake query %q", query) + } + return driver.ManagedTurnResult{TurnID: "noop-turn", Status: "completed"}, nil +} diff --git a/harness/cmd/mnemond/agent_test.go b/harness/cmd/mnemond/agent_test.go new file mode 100644 index 00000000..c6674118 --- /dev/null +++ b/harness/cmd/mnemond/agent_test.go @@ -0,0 +1,101 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/mnemon-dev/mnemon/harness/internal/driver" + eventmodel "github.com/mnemon-dev/mnemon/harness/internal/event" + "github.com/mnemon-dev/mnemon/harness/internal/mnemond/presentation" +) + +func TestAgentRunDryRunPrintsSentinelOnly(t *testing.T) { + var out, errw bytes.Buffer + err := run(context.Background(), []string{"agent", "run", "--principal", "codex-a@project", "--dry-run"}, &out, &errw) + if err != nil { + t.Fatalf("agent run dry-run: %v\nstderr=%s", err, errw.String()) + } + if got := strings.TrimSpace(out.String()); got != driver.ManagedWakeQuery { + t.Fatalf("dry-run output = %q, want %q", got, driver.ManagedWakeQuery) + } +} + +func TestAgentRunRequiresPrincipal(t *testing.T) { + var out, errw bytes.Buffer + err := run(context.Background(), []string{"agent", "run", "--dry-run"}, &out, &errw) + if err == nil || !strings.Contains(err.Error(), "--principal") { + t.Fatalf("missing principal should fail clearly, got err=%v out=%q stderr=%q", err, out.String(), errw.String()) + } +} + +func TestAgentRunNoopRecordsSentinelQuery(t *testing.T) { + var out, errw bytes.Buffer + err := run(context.Background(), []string{"agent", "run", "--principal", "codex-a@project", "--runtime", "noop", "--workspace", t.TempDir()}, &out, &errw) + if err != nil { + t.Fatalf("agent run noop: %v\nstderr=%s", err, errw.String()) + } + if !strings.Contains(out.String(), `"query": "`+driver.ManagedWakeQuery+`"`) { + t.Fatalf("noop run should report sentinel query only:\n%s", out.String()) + } +} + +func TestAgentRunNoopUsesRenderedWakeCandidate(t *testing.T) { + rendered := presentation.Response{ + SchemaVersion: 1, + Status: presentation.StatusOK, + AuditID: "render-1", + BodyDigest: "sha256:render", + Events: []eventmodel.EventEnvelope{eventmodel.DerivedEnvelope(eventmodel.Event{ + SchemaVersion: eventmodel.SchemaVersion, + ID: "derived:assignment.work_available:assignment/asg1:codex-a@project", + Type: "assignment.work_available", + Subject: "assignment/asg1", + Actor: "mnemond@local", + Audience: "codex-a@project", + Payload: eventmodel.BuildPayload(nil, map[string]any{"body": "Assignment asg1 is yours."}, nil), + }, "2026-06-24T10:00:00Z", "2026-06-24T10:05:00Z", "work", nil)}, + } + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/render" { + t.Fatalf("path = %s, want /render", r.URL.Path) + } + if got := r.Header.Get("Authorization"); got != "Bearer local-token" { + t.Fatalf("Authorization = %q, want bearer token", got) + } + _ = json.NewEncoder(w).Encode(rendered) + })) + defer server.Close() + root := t.TempDir() + tokenFile := filepath.Join(root, "token") + if err := os.WriteFile(tokenFile, []byte("local-token\n"), 0o600); err != nil { + t.Fatal(err) + } + var out, errw bytes.Buffer + err := run(context.Background(), []string{ + "agent", "run", + "--principal", "codex-a@project", + "--runtime", "noop", + "--addr", server.URL, + "--token-file", tokenFile, + "--workspace", root, + }, &out, &errw) + if err != nil { + t.Fatalf("agent run noop render: %v\nstderr=%s", err, errw.String()) + } + if !strings.Contains(out.String(), `"query": "`+driver.ManagedWakeQuery+`"`) { + t.Fatalf("render candidate run should report sentinel query only:\n%s", out.String()) + } + if !strings.Contains(out.String(), `"derived_event_id": "derived:assignment.work_available:assignment/asg1:codex-a@project"`) { + t.Fatalf("render candidate did not become wake record:\n%s", out.String()) + } + if !strings.Contains(out.String(), `"render_audit_id": "render-1"`) { + t.Fatalf("wake record should carry render audit id:\n%s", out.String()) + } +} diff --git a/harness/cmd/mnemond/main.go b/harness/cmd/mnemond/main.go index 7e9e5da1..9a2f2c98 100644 --- a/harness/cmd/mnemond/main.go +++ b/harness/cmd/mnemond/main.go @@ -50,6 +50,8 @@ func run(ctx context.Context, args []string, out, errw io.Writer) error { return daemonStatus(args[1:], out, errw) case "logs": return daemonLogs(args[1:], out, errw) + case "agent": + return runAgent(ctx, args[1:], out, errw) case "serve": args = args[1:] } diff --git a/harness/internal/app/setup.go b/harness/internal/app/setup.go index 50ff4206..0169b79d 100644 --- a/harness/internal/app/setup.go +++ b/harness/internal/app/setup.go @@ -30,6 +30,7 @@ type SetupOptions struct { ControlURL string // channel endpoint, e.g. "http://127.0.0.1:8787" Principal string // authenticated principal, e.g. "codex@project" ActorKind string // "host-agent" (default) or "control-agent" + HarnessBin string // command or absolute path agents should use for Local Mnemon CLI calls UseToken bool // generate + reference a bearer token file (vs trusted-header auth) TokenExplicit bool // true when the caller explicitly set UseToken ProjectRoot string // host projection working dir (defaults to the facade root) @@ -341,9 +342,13 @@ func writeHostObserveSkill(projectRoot, host string) error { } func writeLocalEnv(path string, opts SetupOptions, tokenRel string, loops []string) error { + harnessBin := strings.TrimSpace(opts.HarnessBin) + if harnessBin == "" { + harnessBin = "mnemon-harness" + } var b strings.Builder b.WriteString("# Managed by mnemon-harness setup - Local Mnemon environment.\n") - b.WriteString(exportLine("MNEMON_HARNESS_BIN", "mnemon-harness")) + b.WriteString(exportLine("MNEMON_HARNESS_BIN", harnessBin)) b.WriteString(exportLine("MNEMON_CONTROL_ADDR", opts.ControlURL)) b.WriteString(exportLine("MNEMON_CONTROL_PRINCIPAL", opts.Principal)) if tokenRel != "" { diff --git a/harness/internal/assets/guides/mnemon-harness-guide.md b/harness/internal/assets/guides/mnemon-harness-guide.md index 9e4a4141..7ef49459 100644 --- a/harness/internal/assets/guides/mnemon-harness-guide.md +++ b/harness/internal/assets/guides/mnemon-harness-guide.md @@ -7,6 +7,7 @@ Mnemon is the governed event layer for durable agent context. Use it when the cu - Before substantive work, decide whether governed context should be read. - After substantive work, decide whether durable state should be recorded. - Before context compaction, preserve important continuity through Mnemon when needed. +- `[mnemon:wake]` is only a local wake signal. ## Read @@ -15,11 +16,12 @@ Use the generic skill or CLI to inspect current state before acting when the pro Useful commands: ```bash -mnemon-harness control pull -mnemon-harness control render --intent context.packet -mnemon-harness control render --intent teamwork.events -mnemon-harness loop packages -mnemon-harness loop schema --type +. .mnemon/harness/local/env.sh +"${MNEMON_HARNESS_BIN:-mnemon-harness}" control pull +"${MNEMON_HARNESS_BIN:-mnemon-harness}" control render --intent context.packet +"${MNEMON_HARNESS_BIN:-mnemon-harness}" control render --intent teamwork.events +"${MNEMON_HARNESS_BIN:-mnemon-harness}" loop packages +"${MNEMON_HARNESS_BIN:-mnemon-harness}" loop schema --type ``` ## Record @@ -29,26 +31,27 @@ Emit governed events through Mnemon. Do not write `.mnemon` state files directly Prefer the short teamwork/profile commands when they fit the event you need: ```bash -mnemon-harness control teamwork signal \ +. .mnemon/harness/local/env.sh +"${MNEMON_HARNESS_BIN:-mnemon-harness}" control teamwork signal \ --scope \ --statement "" \ --why-teamwork "" \ --evidence "" \ --external-id -mnemon-harness control teamwork assign \ +"${MNEMON_HARNESS_BIN:-mnemon-harness}" control teamwork assign \ --assignee \ --scope \ --work "" \ --evidence "" \ --external-id -mnemon-harness control teamwork progress \ +"${MNEMON_HARNESS_BIN:-mnemon-harness}" control teamwork progress \ --assignment-ref \ --summary "" \ --external-id -mnemon-harness control profile update \ +"${MNEMON_HARNESS_BIN:-mnemon-harness}" control profile update \ --focus "" \ --advantage "" \ --summary "" \ @@ -58,13 +61,14 @@ mnemon-harness control profile update \ Use the low-level observe API only when you need fields the short commands do not expose: ```bash -mnemon-harness control observe \ +. .mnemon/harness/local/env.sh +"${MNEMON_HARNESS_BIN:-mnemon-harness}" control observe \ --type .write_candidate.observed \ --payload '{"rule":{...},"narrative":{...},"refs":{...}}' \ --external-id ``` -Check `mnemon-harness loop schema --type ` before guessing payload fields. +Check `"${MNEMON_HARNESS_BIN:-mnemon-harness}" loop schema --type ` before guessing payload fields. ## Teamwork Events diff --git a/harness/internal/coreguard/acceptance_boundary_test.go b/harness/internal/coreguard/acceptance_boundary_test.go new file mode 100644 index 00000000..d0c39c69 --- /dev/null +++ b/harness/internal/coreguard/acceptance_boundary_test.go @@ -0,0 +1,72 @@ +package coreguard + +import ( + "go/parser" + "go/token" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestAcceptanceCommandIsIsolatedFromProductCommands(t *testing.T) { + productCmdDir := filepath.Join("..", "..", "cmd", "mnemon-harness") + entries, err := os.ReadDir(productCmdDir) + if err != nil { + t.Fatalf("read product command dir: %v", err) + } + for _, entry := range entries { + if entry.IsDir() { + continue + } + name := entry.Name() + if strings.HasPrefix(name, "acceptance") && strings.HasSuffix(name, ".go") { + t.Fatalf("test-only acceptance source %q must not live under mnemon-harness", name) + } + } + if !hasNonTestGoFiles(filepath.Join("..", "..", "cmd", "mnemon-acceptance")) { + t.Fatalf("mnemon-acceptance command must own acceptance scenarios") + } +} + +func TestProductCodeDoesNotImportAcceptanceCommand(t *testing.T) { + root := filepath.Join("..", "..") + for _, dir := range []string{ + filepath.Join(root, "internal"), + filepath.Join(root, "cmd", "mnemon-harness"), + filepath.Join(root, "cmd", "mnemond"), + filepath.Join(root, "cmd", "mnemon-hub"), + } { + assertNoAcceptanceCommandImports(t, dir) + } +} + +func assertNoAcceptanceCommandImports(t *testing.T, dir string) { + t.Helper() + err := filepath.WalkDir(dir, func(path string, entry os.DirEntry, err error) error { + if err != nil { + return err + } + if entry.IsDir() { + return nil + } + if !strings.HasSuffix(path, ".go") { + return nil + } + fset := token.NewFileSet() + file, err := parser.ParseFile(fset, path, nil, parser.ImportsOnly) + if err != nil { + return err + } + for _, imp := range file.Imports { + importPath := strings.Trim(imp.Path.Value, `"`) + if strings.Contains(importPath, "harness/cmd/mnemon-acceptance") { + t.Errorf("%s imports test-only acceptance command %q", path, importPath) + } + } + return nil + }) + if err != nil { + t.Fatalf("walk %s: %v", dir, err) + } +} diff --git a/harness/internal/driver/managed_agent.go b/harness/internal/driver/managed_agent.go new file mode 100644 index 00000000..9e09548f --- /dev/null +++ b/harness/internal/driver/managed_agent.go @@ -0,0 +1,206 @@ +package driver + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "sync" + "time" + + eventmodel "github.com/mnemon-dev/mnemon/harness/internal/event" +) + +const ManagedWakeQuery = "[mnemon:wake]" + +type ManagedTurnClient interface { + StartTurn(context.Context, string) (ManagedTurnResult, error) +} + +type ManagedTurnResult struct { + TurnID string + Status string + FinalAnswer string +} + +type ManagedWakeCandidate struct { + Principal string + DerivedEventID string + BodyDigest string + Reason string + RenderAuditID string + RenderBodyDigest string +} + +type ManagedWakeRecord struct { + Principal string `json:"principal"` + DerivedEventID string `json:"derived_event_id"` + BodyDigest string `json:"body_digest"` + Reason string `json:"reason"` + Query string `json:"query"` + TurnID string `json:"turn_id,omitempty"` + Status string `json:"status"` + StartedAt time.Time `json:"started_at"` + RenderAuditID string `json:"render_audit_id,omitempty"` + RenderBodyDigest string `json:"render_body_digest,omitempty"` + FinalAnswer string `json:"final_answer,omitempty"` + Error string `json:"error,omitempty"` +} + +type ManagedWakeLedger interface { + Seen(ManagedWakeCandidate) bool + Record(ManagedWakeRecord) error +} + +type MemoryManagedWakeLedger struct { + mu sync.Mutex + seen map[string]ManagedWakeRecord +} + +func NewMemoryManagedWakeLedger() *MemoryManagedWakeLedger { + return &MemoryManagedWakeLedger{seen: map[string]ManagedWakeRecord{}} +} + +func (l *MemoryManagedWakeLedger) Seen(candidate ManagedWakeCandidate) bool { + l.mu.Lock() + defer l.mu.Unlock() + _, ok := l.seen[managedWakeKey(candidate)] + return ok +} + +func (l *MemoryManagedWakeLedger) Record(record ManagedWakeRecord) error { + l.mu.Lock() + defer l.mu.Unlock() + l.seen[managedWakeKey(ManagedWakeCandidate{ + Principal: record.Principal, + DerivedEventID: record.DerivedEventID, + BodyDigest: record.BodyDigest, + })] = record + return nil +} + +type ManagedAgentDriver struct { + Principal string + Client ManagedTurnClient + Ledger ManagedWakeLedger + Cooldown time.Duration + Now func() time.Time + + mu sync.Mutex + inFlight bool + lastWake time.Time +} + +func (d *ManagedAgentDriver) Wake(ctx context.Context, candidate ManagedWakeCandidate) (ManagedWakeRecord, error) { + if d.Principal == "" { + return ManagedWakeRecord{}, fmt.Errorf("managed agent driver requires a principal") + } + if candidate.Principal != d.Principal { + return ManagedWakeRecord{}, fmt.Errorf("wake candidate principal %q does not match local principal %q", candidate.Principal, d.Principal) + } + if candidate.DerivedEventID == "" || candidate.BodyDigest == "" { + return ManagedWakeRecord{}, fmt.Errorf("wake candidate requires derived event id and body digest") + } + if d.Client == nil { + return ManagedWakeRecord{}, fmt.Errorf("managed agent driver requires a turn client") + } + ledger := d.Ledger + if ledger == nil { + ledger = NewMemoryManagedWakeLedger() + d.Ledger = ledger + } + now := d.now() + d.mu.Lock() + if d.inFlight { + d.mu.Unlock() + return ManagedWakeRecord{}, fmt.Errorf("managed wake already in flight") + } + if d.Cooldown > 0 && !d.lastWake.IsZero() && now.Sub(d.lastWake) < d.Cooldown { + d.mu.Unlock() + return ManagedWakeRecord{}, fmt.Errorf("managed wake cooldown active") + } + if ledger.Seen(candidate) { + d.mu.Unlock() + return ManagedWakeRecord{}, fmt.Errorf("managed wake candidate already handled") + } + d.inFlight = true + d.mu.Unlock() + + record := ManagedWakeRecord{ + Principal: candidate.Principal, + DerivedEventID: candidate.DerivedEventID, + BodyDigest: candidate.BodyDigest, + Reason: candidate.Reason, + Query: ManagedWakeQuery, + StartedAt: now, + Status: "started", + RenderAuditID: candidate.RenderAuditID, + RenderBodyDigest: candidate.RenderBodyDigest, + } + result, err := d.Client.StartTurn(ctx, ManagedWakeQuery) + if err != nil { + record.Status = "failed" + record.Error = err.Error() + _ = ledger.Record(record) + d.clearInFlight() + return record, err + } + record.TurnID = result.TurnID + record.FinalAnswer = result.FinalAnswer + record.Status = result.Status + if record.Status == "" { + record.Status = "completed" + } + if err := ledger.Record(record); err != nil { + d.clearInFlight() + return record, err + } + d.mu.Lock() + d.lastWake = now + d.inFlight = false + d.mu.Unlock() + return record, nil +} + +func (d *ManagedAgentDriver) clearInFlight() { + d.mu.Lock() + d.inFlight = false + d.mu.Unlock() +} + +func (d *ManagedAgentDriver) now() time.Time { + if d.Now != nil { + return d.Now().UTC() + } + return time.Now().UTC() +} + +func ManagedWakeCandidatesFromEvents(principal string, events []eventmodel.EventEnvelope) []ManagedWakeCandidate { + var out []ManagedWakeCandidate + for _, env := range events { + if string(env.Event.Audience) != principal { + continue + } + reason, _ := env.Meta["presentation_hint"].(string) + if reason == "" { + continue + } + body, _ := eventmodel.PayloadNarrative(env.Event.Payload)["body"].(string) + out = append(out, ManagedWakeCandidate{ + Principal: principal, + DerivedEventID: env.Event.ID, + BodyDigest: managedBodyDigest(body), + Reason: reason, + }) + } + return out +} + +func managedBodyDigest(body string) string { + sum := sha256.Sum256([]byte(body)) + return "sha256:" + hex.EncodeToString(sum[:]) +} + +func managedWakeKey(candidate ManagedWakeCandidate) string { + return candidate.Principal + "|" + candidate.DerivedEventID + "|" + candidate.BodyDigest +} diff --git a/harness/internal/driver/managed_agent_test.go b/harness/internal/driver/managed_agent_test.go new file mode 100644 index 00000000..9ac75688 --- /dev/null +++ b/harness/internal/driver/managed_agent_test.go @@ -0,0 +1,118 @@ +package driver + +import ( + "context" + "errors" + "testing" + "time" + + eventmodel "github.com/mnemon-dev/mnemon/harness/internal/event" +) + +type managedTurnClientFunc func(context.Context, string) (ManagedTurnResult, error) + +func (f managedTurnClientFunc) StartTurn(ctx context.Context, query string) (ManagedTurnResult, error) { + return f(ctx, query) +} + +func TestManagedAgentDriverUsesSentinelOnly(t *testing.T) { + var gotQuery string + driver := &ManagedAgentDriver{ + Principal: "codex-a@project", + Client: managedTurnClientFunc(func(_ context.Context, query string) (ManagedTurnResult, error) { + gotQuery = query + return ManagedTurnResult{TurnID: "turn-1", Status: "completed"}, nil + }), + Ledger: NewMemoryManagedWakeLedger(), + Now: func() time.Time { return time.Unix(100, 0).UTC() }, + } + record, err := driver.Wake(context.Background(), ManagedWakeCandidate{ + Principal: "codex-a@project", + DerivedEventID: "derived:assignment.work_available:assignment/asg1:codex-a@project", + BodyDigest: "sha256:body", + Reason: "work", + }) + if err != nil { + t.Fatal(err) + } + if gotQuery != ManagedWakeQuery || record.Query != ManagedWakeQuery { + t.Fatalf("managed wake query = %q / %q, want %q", gotQuery, record.Query, ManagedWakeQuery) + } +} + +func TestManagedAgentDriverRejectsRemotePrincipal(t *testing.T) { + driver := &ManagedAgentDriver{ + Principal: "codex-a@project", + Client: managedTurnClientFunc(func(context.Context, string) (ManagedTurnResult, error) { return ManagedTurnResult{}, nil }), + Ledger: NewMemoryManagedWakeLedger(), + } + if _, err := driver.Wake(context.Background(), ManagedWakeCandidate{Principal: "codex-b@project", DerivedEventID: "d1", BodyDigest: "sha256:x"}); err == nil { + t.Fatal("managed driver must reject candidates for non-local principals") + } +} + +func TestManagedAgentDriverDedupesAndCoolsDown(t *testing.T) { + now := time.Unix(100, 0).UTC() + driver := &ManagedAgentDriver{ + Principal: "codex-a@project", + Client: managedTurnClientFunc(func(context.Context, string) (ManagedTurnResult, error) { return ManagedTurnResult{}, nil }), + Ledger: NewMemoryManagedWakeLedger(), + Cooldown: time.Minute, + Now: func() time.Time { return now }, + } + candidate := ManagedWakeCandidate{Principal: "codex-a@project", DerivedEventID: "d1", BodyDigest: "sha256:x"} + if _, err := driver.Wake(context.Background(), candidate); err != nil { + t.Fatal(err) + } + if _, err := driver.Wake(context.Background(), candidate); err == nil { + t.Fatal("duplicate candidate should not wake twice") + } + now = now.Add(10 * time.Second) + if _, err := driver.Wake(context.Background(), ManagedWakeCandidate{Principal: "codex-a@project", DerivedEventID: "d2", BodyDigest: "sha256:y"}); err == nil { + t.Fatal("cooldown should block a different rapid wake") + } +} + +func TestManagedAgentDriverRecordsFailureWithoutChangingQuery(t *testing.T) { + ledger := NewMemoryManagedWakeLedger() + driver := &ManagedAgentDriver{ + Principal: "codex-a@project", + Client: managedTurnClientFunc(func(_ context.Context, query string) (ManagedTurnResult, error) { + if query != ManagedWakeQuery { + t.Fatalf("query = %q, want %q", query, ManagedWakeQuery) + } + return ManagedTurnResult{}, errors.New("runtime unavailable") + }), + Ledger: ledger, + } + candidate := ManagedWakeCandidate{Principal: "codex-a@project", DerivedEventID: "d1", BodyDigest: "sha256:x"} + record, err := driver.Wake(context.Background(), candidate) + if err == nil { + t.Fatal("wake should surface runtime failure") + } + if record.Status != "failed" || record.Query != ManagedWakeQuery { + t.Fatalf("failed record mismatch: %+v", record) + } + if !ledger.Seen(candidate) { + t.Fatal("failure should be recorded locally for audit/idempotence") + } +} + +func TestManagedWakeCandidatesFromDerivedEvents(t *testing.T) { + env := eventmodel.DerivedEnvelope(eventmodel.Event{ + SchemaVersion: eventmodel.SchemaVersion, + ID: "derived:work", + Type: "assignment.work_available", + Subject: "assignment/asg1", + Actor: "mnemond@local", + Audience: "codex-a@project", + Payload: eventmodel.BuildPayload(nil, map[string]any{"body": "Assignment asg1 is yours."}, nil), + }, "2026-06-24T10:00:00Z", "2026-06-24T10:05:00Z", "work", nil) + candidates := ManagedWakeCandidatesFromEvents("codex-a@project", []eventmodel.EventEnvelope{env}) + if len(candidates) != 1 { + t.Fatalf("candidates = %+v, want one", candidates) + } + if candidates[0].Reason != "work" || candidates[0].BodyDigest == "" { + t.Fatalf("candidate should carry reason and body digest: %+v", candidates[0]) + } +} diff --git a/harness/internal/driver/managed_runtime.go b/harness/internal/driver/managed_runtime.go new file mode 100644 index 00000000..41ce2710 --- /dev/null +++ b/harness/internal/driver/managed_runtime.go @@ -0,0 +1,343 @@ +package driver + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/mnemon-dev/mnemon/harness/internal/codexapp" + "github.com/mnemon-dev/mnemon/harness/internal/contract" + "github.com/mnemon-dev/mnemon/harness/internal/mnemond/access" + "github.com/mnemon-dev/mnemon/harness/internal/mnemond/presentation" +) + +type HTTPRenderClient struct { + BaseURL string + Token string + Principal contract.ActorID + HTTP *http.Client +} + +func (c HTTPRenderClient) Render(ctx context.Context, req presentation.Request) (presentation.Response, error) { + base := strings.TrimRight(strings.TrimSpace(c.BaseURL), "/") + if base == "" { + return presentation.Response{}, fmt.Errorf("render client requires base URL") + } + req.Principal = c.Principal + body, err := json.Marshal(req) + if err != nil { + return presentation.Response{}, err + } + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, base+"/render", bytes.NewReader(body)) + if err != nil { + return presentation.Response{}, err + } + if strings.TrimSpace(c.Token) != "" { + httpReq.Header.Set("Authorization", "Bearer "+strings.TrimSpace(c.Token)) + } else { + httpReq.Header.Set(access.PrincipalHeader, string(c.Principal)) + } + httpReq.Header.Set("Content-Type", "application/json") + client := c.HTTP + if client == nil { + client = http.DefaultClient + } + resp, err := client.Do(httpReq) + if err != nil { + return presentation.Response{}, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + data, _ := io.ReadAll(resp.Body) + return presentation.Response{}, fmt.Errorf("render failed: %s: %s", resp.Status, strings.TrimSpace(string(data))) + } + var out presentation.Response + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return presentation.Response{}, err + } + return out, nil +} + +func ManagedWakeCandidatesFromRender(principal string, resp presentation.Response) []ManagedWakeCandidate { + candidates := ManagedWakeCandidatesFromEvents(principal, resp.Events) + for i := range candidates { + candidates[i].RenderAuditID = resp.AuditID + candidates[i].RenderBodyDigest = resp.BodyDigest + } + return candidates +} + +type FileManagedWakeLedger struct { + path string + mu sync.Mutex + loaded bool + loadErr error + seen map[string]ManagedWakeRecord +} + +func NewFileManagedWakeLedger(path string) *FileManagedWakeLedger { + return &FileManagedWakeLedger{path: path, seen: map[string]ManagedWakeRecord{}} +} + +func (l *FileManagedWakeLedger) Seen(candidate ManagedWakeCandidate) bool { + l.mu.Lock() + defer l.mu.Unlock() + l.loadLocked() + _, ok := l.seen[managedWakeKey(candidate)] + return ok +} + +func (l *FileManagedWakeLedger) Record(record ManagedWakeRecord) error { + l.mu.Lock() + defer l.mu.Unlock() + l.loadLocked() + if l.loadErr != nil { + return l.loadErr + } + if strings.TrimSpace(l.path) == "" { + return fmt.Errorf("managed wake ledger path is required") + } + if err := os.MkdirAll(filepath.Dir(l.path), 0o755); err != nil { + return err + } + f, err := os.OpenFile(l.path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600) + if err != nil { + return err + } + enc := json.NewEncoder(f) + if err := enc.Encode(record); err != nil { + _ = f.Close() + return err + } + if err := f.Close(); err != nil { + return err + } + l.seen[managedWakeKey(ManagedWakeCandidate{ + Principal: record.Principal, + DerivedEventID: record.DerivedEventID, + BodyDigest: record.BodyDigest, + })] = record + return nil +} + +func (l *FileManagedWakeLedger) loadLocked() { + if l.loaded { + return + } + l.loaded = true + if strings.TrimSpace(l.path) == "" { + l.loadErr = fmt.Errorf("managed wake ledger path is required") + return + } + f, err := os.Open(l.path) + if errors.Is(err, os.ErrNotExist) { + return + } + if err != nil { + l.loadErr = err + return + } + defer f.Close() + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + var record ManagedWakeRecord + if err := json.Unmarshal([]byte(line), &record); err != nil { + l.loadErr = err + return + } + l.seen[managedWakeKey(ManagedWakeCandidate{ + Principal: record.Principal, + DerivedEventID: record.DerivedEventID, + BodyDigest: record.BodyDigest, + })] = record + } + if err := scanner.Err(); err != nil { + l.loadErr = err + } +} + +type CodexAppServerTurnClient struct { + Principal string + Command string + Workspace string + Env []string + TurnTimeout time.Duration + RequestTimeout time.Duration + ClientName string + ClientVersion string +} + +func (c CodexAppServerTurnClient) StartTurn(ctx context.Context, query string) (ManagedTurnResult, error) { + if strings.TrimSpace(query) != ManagedWakeQuery { + return ManagedTurnResult{}, fmt.Errorf("managed codex appserver client only accepts %q queries", ManagedWakeQuery) + } + command := strings.TrimSpace(c.Command) + if command == "" { + command = "codex" + } + workspace := strings.TrimSpace(c.Workspace) + if workspace == "" { + workspace = "." + } + requestTimeout := c.RequestTimeout + if requestTimeout <= 0 { + requestTimeout = 30 * time.Second + } + turnTimeout := c.TurnTimeout + if turnTimeout <= 0 { + turnTimeout = 5 * time.Minute + } + clientName := strings.TrimSpace(c.ClientName) + if clientName == "" { + clientName = "mnemond-managed-agent" + } + clientVersion := strings.TrimSpace(c.ClientVersion) + if clientVersion == "" { + clientVersion = "dev" + } + server := codexapp.New(command, workspace) + if len(c.Env) > 0 { + server.SetEnv(c.Env) + } + if err := server.Start(); err != nil { + return ManagedTurnResult{}, err + } + defer server.Close() + if _, err := server.Request("initialize", map[string]any{ + "clientInfo": map[string]any{"name": clientName, "version": clientVersion}, + "capabilities": codexAppServerCapabilities(), + }, requestTimeout); err != nil { + return ManagedTurnResult{}, fmt.Errorf("initialize: %w", err) + } + threadParams := map[string]any{ + "cwd": workspace, + "approvalPolicy": "never", + "sandbox": "danger-full-access", + "ephemeral": true, + } + thread, err := server.Request("thread/start", threadParams, requestTimeout) + if err != nil { + return ManagedTurnResult{}, fmt.Errorf("thread/start: %w", err) + } + threadID := codexapp.ThreadID(thread) + if threadID == "" { + return ManagedTurnResult{}, fmt.Errorf("thread/start returned no thread id") + } + additionalContext, err := CodexAppServerAdditionalContext(ctx, workspace, c.Env, ManagedWakeQuery) + if err != nil { + return ManagedTurnResult{}, fmt.Errorf("load hook context: %w", err) + } + turnParams := map[string]any{ + "threadId": threadID, + "input": []map[string]any{{"type": "text", "text": ManagedWakeQuery}}, + "cwd": workspace, + "approvalPolicy": "never", + "sandboxPolicy": map[string]any{"type": "dangerFullAccess"}, + } + if len(additionalContext) > 0 { + turnParams["additionalContext"] = additionalContext + } + before := server.NotificationCount() + if _, err := server.Request("turn/start", turnParams, requestTimeout); err != nil { + return ManagedTurnResult{}, fmt.Errorf("turn/start: %w", err) + } + if _, err := server.WaitNotification("turn/completed", turnTimeout, before); err != nil { + text := codexapp.CombinedText(server.NotificationsSince(before)) + return ManagedTurnResult{TurnID: threadID, Status: "failed", FinalAnswer: text}, fmt.Errorf("wait turn/completed: %w", err) + } + notifications := server.NotificationsSince(before) + answer := codexapp.FinalAnswer(notifications) + if answer == "" { + answer = codexapp.CombinedText(notifications) + } + return ManagedTurnResult{TurnID: threadID, Status: "completed", FinalAnswer: answer}, nil +} + +func CodexAppServerAdditionalContext(ctx context.Context, workspace string, env []string, query string) (map[string]any, error) { + out := map[string]any{} + for _, lifecycle := range []string{"prime", "remind"} { + message, err := runCodexLifecycleHook(ctx, workspace, env, lifecycle, query) + if err != nil { + return nil, err + } + if strings.TrimSpace(message) == "" { + continue + } + out["mnemon.hook."+lifecycle] = map[string]any{ + "kind": "application", + "value": message, + } + } + return out, nil +} + +func runCodexLifecycleHook(ctx context.Context, workspace string, env []string, lifecycle string, query string) (string, error) { + hook := filepath.Join(workspace, ".codex", "hooks", "mnemon-r1", lifecycle+".sh") + if _, err := os.Stat(hook); errors.Is(err, os.ErrNotExist) { + return "", nil + } else if err != nil { + return "", err + } + stdin := "{}" + if lifecycle == "remind" { + raw, err := json.Marshal(map[string]string{"prompt": query}) + if err != nil { + return "", err + } + stdin = string(raw) + } + cmd := exec.CommandContext(ctx, "bash", hook) + cmd.Dir = workspace + if len(env) > 0 { + cmd.Env = append([]string(nil), env...) + } + cmd.Stdin = strings.NewReader(stdin) + var stdout bytes.Buffer + var stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + detail := strings.TrimSpace(stderr.String()) + if detail != "" { + return "", fmt.Errorf("%s hook failed: %w: %s", lifecycle, err, detail) + } + return "", fmt.Errorf("%s hook failed: %w", lifecycle, err) + } + return codexHookSystemMessage(stdout.String()), nil +} + +func codexHookSystemMessage(raw string) string { + text := strings.TrimSpace(raw) + if text == "" { + return "" + } + var envelope map[string]any + if err := json.Unmarshal([]byte(text), &envelope); err == nil { + if message, ok := envelope["systemMessage"].(string); ok { + return strings.TrimSpace(message) + } + } + return text +} + +func codexAppServerCapabilities() map[string]any { + return map[string]any{ + "experimentalApi": true, + "requestAttestation": false, + } +} diff --git a/harness/internal/driver/managed_runtime_test.go b/harness/internal/driver/managed_runtime_test.go new file mode 100644 index 00000000..56fa1ee2 --- /dev/null +++ b/harness/internal/driver/managed_runtime_test.go @@ -0,0 +1,132 @@ +package driver + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + eventmodel "github.com/mnemon-dev/mnemon/harness/internal/event" + "github.com/mnemon-dev/mnemon/harness/internal/mnemond/presentation" +) + +func TestManagedWakeCandidatesFromRenderCarryAudit(t *testing.T) { + resp := presentation.Response{ + AuditID: "render_audit_1", + BodyDigest: "sha256:render", + Events: []eventmodel.EventEnvelope{eventmodel.DerivedEnvelope(eventmodel.Event{ + SchemaVersion: eventmodel.SchemaVersion, + ID: "derived:assignment.work_available:assignment/asg1:codex-a@project", + Type: "assignment.work_available", + Subject: "assignment/asg1", + Actor: "mnemond@local", + Audience: "codex-a@project", + Payload: eventmodel.BuildPayload(nil, map[string]any{"body": "Assignment asg1 is yours."}, nil), + }, "2026-06-24T10:00:00Z", "2026-06-24T10:05:00Z", "work", nil)}, + } + candidates := ManagedWakeCandidatesFromRender("codex-a@project", resp) + if len(candidates) != 1 { + t.Fatalf("candidates = %+v, want one", candidates) + } + if candidates[0].RenderAuditID != resp.AuditID || candidates[0].RenderBodyDigest != resp.BodyDigest { + t.Fatalf("candidate should carry render audit metadata: %+v", candidates[0]) + } +} + +func TestFileManagedWakeLedgerPersistsSeen(t *testing.T) { + path := filepath.Join(t.TempDir(), "wake-ledger.jsonl") + candidate := ManagedWakeCandidate{Principal: "codex-a@project", DerivedEventID: "d1", BodyDigest: "sha256:x"} + ledger := NewFileManagedWakeLedger(path) + if ledger.Seen(candidate) { + t.Fatal("fresh ledger should not have seen candidate") + } + if err := ledger.Record(ManagedWakeRecord{Principal: candidate.Principal, DerivedEventID: candidate.DerivedEventID, BodyDigest: candidate.BodyDigest, Status: "completed"}); err != nil { + t.Fatal(err) + } + reopened := NewFileManagedWakeLedger(path) + if !reopened.Seen(candidate) { + t.Fatal("reopened ledger should remember candidate") + } +} + +func TestHTTPRenderClientUsesBearerAndDecodesResponse(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("Authorization"); got != "Bearer token-1" { + t.Fatalf("Authorization = %q, want bearer token", got) + } + _ = json.NewEncoder(w).Encode(presentation.Response{SchemaVersion: 1, Status: presentation.StatusOK, AuditID: "audit-1"}) + })) + defer server.Close() + resp, err := (HTTPRenderClient{BaseURL: server.URL, Token: "token-1"}).Render(context.Background(), presentation.Request{}) + if err != nil { + t.Fatal(err) + } + if resp.AuditID != "audit-1" { + t.Fatalf("render response = %+v", resp) + } +} + +func TestCodexAppServerTurnClientRejectsContextQuery(t *testing.T) { + client := CodexAppServerTurnClient{Command: "definitely-not-run"} + if _, err := client.StartTurn(context.Background(), "assignment asg1"); err == nil { + t.Fatal("codex appserver client must reject non-sentinel queries before starting a process") + } +} + +func TestCodexAppServerAdditionalContextRunsStandardHooks(t *testing.T) { + workspace := t.TempDir() + hookDir := filepath.Join(workspace, ".codex", "hooks", "mnemon-r1") + if err := os.MkdirAll(hookDir, 0o755); err != nil { + t.Fatal(err) + } + prime := filepath.Join(hookDir, "prime.sh") + if err := os.WriteFile(prime, []byte(`#!/usr/bin/env bash +printf '{"systemMessage":"prime guide loaded"}\n' +`), 0o755); err != nil { + t.Fatal(err) + } + remind := filepath.Join(hookDir, "remind.sh") + if err := os.WriteFile(remind, []byte(`#!/usr/bin/env bash +INPUT="$(cat || true)" +case "${INPUT}" in + *"[mnemon:wake]"*) printf '{"systemMessage":"remind rendered governed context"}\n' ;; + *) printf '{"systemMessage":"remind generic"}\n' ;; +esac +`), 0o755); err != nil { + t.Fatal(err) + } + + ctx, err := CodexAppServerAdditionalContext(context.Background(), workspace, os.Environ(), ManagedWakeQuery) + if err != nil { + t.Fatal(err) + } + if len(ctx) != 2 { + t.Fatalf("additional context keys = %+v, want prime and remind", ctx) + } + for key, want := range map[string]string{ + "mnemon.hook.prime": "prime guide loaded", + "mnemon.hook.remind": "remind rendered governed context", + } { + entry, ok := ctx[key].(map[string]any) + if !ok { + t.Fatalf("%s entry = %#v", key, ctx[key]) + } + if entry["kind"] != "application" || !strings.Contains(entry["value"].(string), want) { + t.Fatalf("%s entry = %#v, want application context containing %q", key, entry, want) + } + } +} + +func TestCodexAppServerAdditionalContextSkipsMissingHooks(t *testing.T) { + ctx, err := CodexAppServerAdditionalContext(context.Background(), t.TempDir(), nil, ManagedWakeQuery) + if err != nil { + t.Fatal(err) + } + if len(ctx) != 0 { + t.Fatalf("additional context = %+v, want empty when hooks are absent", ctx) + } +} diff --git a/harness/internal/hostagent/thinshim.go b/harness/internal/hostagent/thinshim.go index c833c850..ee3a39f8 100644 --- a/harness/internal/hostagent/thinshim.go +++ b/harness/internal/hostagent/thinshim.go @@ -118,7 +118,22 @@ func hookBodyBlock(timing string) string { `fi`, }, "\n") case "remind": - return `HOOK_BODY="[mnemon] Evaluate whether governed context should be read before responding."` + return strings.Join([]string{ + `HOOK_BODY="[mnemon] Evaluate whether governed context should be read before responding."`, + `if printf '%s' "${INPUT}" | grep -q '\[mnemon:wake\]'; then`, + ` if [[ -n "${MNEMON_CONTROL_ADDR:-}" && -n "${MNEMON_CONTROL_PRINCIPAL:-}" ]]; then`, + ` TOKEN_ARGS=()`, + ` if [[ -n "${MNEMON_CONTROL_TOKEN_FILE:-}" ]]; then`, + ` TOKEN_ARGS=(--token-file "${MNEMON_CONTROL_TOKEN_FILE}")`, + ` fi`, + ` if RENDERED="$(cd "${PROJECT_ROOT}" && "${MNEMON_HARNESS_BIN:-mnemon-harness}" control render --addr "${MNEMON_CONTROL_ADDR}" --principal "${MNEMON_CONTROL_PRINCIPAL}" "${TOKEN_ARGS[@]}" --intent teamwork.events --lifecycle remind --surface hook 2>/dev/null)"; then`, + ` if [[ -n "${RENDERED}" ]]; then`, + ` HOOK_BODY="$(printf '%s\n\n%s' "[mnemon] Wake signal received. Current governed context:" "${RENDERED}")"`, + ` fi`, + ` fi`, + ` fi`, + `fi`, + }, "\n") case "nudge": return `HOOK_BODY="[mnemon] Evaluate whether this turn changed durable state that should be recorded."` case "compact": diff --git a/harness/internal/hostagent/thinshim_test.go b/harness/internal/hostagent/thinshim_test.go index cca1f124..6cdd12c5 100644 --- a/harness/internal/hostagent/thinshim_test.go +++ b/harness/internal/hostagent/thinshim_test.go @@ -19,6 +19,8 @@ func TestRenderThinHookIsGenericLifecycleShim(t *testing.T) { `LOCAL_ENV="${PROJECT_ROOT}/.mnemon/harness/local/env.sh"`, `GUIDE_PATH="${PROJECT_ROOT}/.mnemon/harness/local/guide.md"`, "Evaluate whether governed context should be read before responding.", + `grep -q '\[mnemon:wake\]'`, + `--intent teamwork.events --lifecycle remind --surface hook`, `"systemMessage": "${SYSTEM_MESSAGE}"`, } { if !strings.Contains(body, want) { @@ -27,10 +29,8 @@ func TestRenderThinHookIsGenericLifecycleShim(t *testing.T) { } for _, blocked := range []string{ "MEMORY.md", - "control render", "control pull", "control observe", - "teamwork", "assignment", "progress_digest", "agent_profile", diff --git a/harness/internal/mnemond/presentation/items.go b/harness/internal/mnemond/presentation/items.go index 81ec8cbb..7278dbfa 100644 --- a/harness/internal/mnemond/presentation/items.go +++ b/harness/internal/mnemond/presentation/items.go @@ -45,6 +45,41 @@ func itemString(item map[string]any, key string) string { return "" } +func itemStringList(item map[string]any, key string) []string { + if out := stringList(item[key]); len(out) > 0 { + return out + } + for _, section := range []string{eventmodel.PayloadRuleKey, eventmodel.PayloadNarrativeKey, eventmodel.PayloadRefsKey} { + if m, ok := item[section].(map[string]any); ok { + if out := stringList(m[key]); len(out) > 0 { + return out + } + } + } + return nil +} + +func stringList(raw any) []string { + var out []string + switch v := raw.(type) { + case []string: + for _, s := range v { + if s = strings.TrimSpace(s); s != "" { + out = append(out, s) + } + } + case []any: + for _, item := range v { + if s, ok := item.(string); ok { + if s = strings.TrimSpace(s); s != "" { + out = append(out, s) + } + } + } + } + return out +} + func firstNonEmpty(item map[string]any, keys ...string) string { for _, key := range keys { if s := itemString(item, key); s != "" { diff --git a/harness/internal/mnemond/presentation/render_test.go b/harness/internal/mnemond/presentation/render_test.go index 852d7383..84dca534 100644 --- a/harness/internal/mnemond/presentation/render_test.go +++ b/harness/internal/mnemond/presentation/render_test.go @@ -104,8 +104,8 @@ func TestDeriveEventEnvelopesSeparateEventModelFromPresentation(t *testing.T) { }} envelopes := DeriveEventEnvelopes(reqB, proj, now) - if len(envelopes) != 3 { - t.Fatalf("expected profile/work/feedback derived envelopes, got %+v", envelopes) + if len(envelopes) != 2 { + t.Fatalf("expected work/feedback derived envelopes, got %+v", envelopes) } got := map[string]eventmodel.EventEnvelope{} for _, env := range envelopes { @@ -147,6 +147,64 @@ func TestDeriveEventEnvelopesSeparateEventModelFromPresentation(t *testing.T) { } } +func TestProfileCuePolicyFollowsLifecycle(t *testing.T) { + now := mustTime(t, "2026-06-24T10:00:00Z") + proj := view.View{Ref: "proj_profile", Digest: "digest_profile", Content: []view.ResourceContent{ + content("teamwork_signal", "project", []any{map[string]any{"id": "sig1", "statement": "Need teammate context"}}), + }} + + prime := DeriveEventEnvelopes(Request{Principal: "codex-a@project", Lifecycle: "prime", RenderIntent: IntentTeamworkEvents}, proj, now) + if _, ok := eventByType(prime, DerivedEventProfileUpdateRequested); !ok { + t.Fatalf("prime with missing profile should render a bounded profile cue: %+v", prime) + } + + remind := DeriveEventEnvelopes(Request{Principal: "codex-a@project", Lifecycle: "remind", RenderIntent: IntentTeamworkEvents}, proj, now) + if _, ok := eventByType(remind, DerivedEventProfileUpdateRequested); !ok { + t.Fatalf("remind with open teamwork signal should render a contextual profile cue: %+v", remind) + } + + workOnly := view.View{Ref: "proj_work", Digest: "digest_work", Content: []view.ResourceContent{ + content("assignment", "project", []any{map[string]any{ + "id": "asg1", "actor": "codex-b@project", "assignee": "codex-a@project", + "scope": "review render presentation", "expected_work": "review render presentation", + "ttl": "30m", "created_at": "2026-06-24T09:45:00Z", + }}), + }} + nudge := DeriveEventEnvelopes(Request{Principal: "codex-a@project", Lifecycle: "nudge", RenderIntent: IntentTeamworkEvents}, workOnly, now) + if _, ok := eventByType(nudge, DerivedEventProfileUpdateRequested); ok { + t.Fatalf("nudge should not render profile cue merely because profile is missing: %+v", nudge) + } + + changed := view.View{Ref: "proj_changed", Digest: "digest_changed", Content: []view.ResourceContent{ + content("progress_digest", "project", []any{map[string]any{ + "id": "pg1", "actor": "codex-a@project", "feedback_kind": "progress", + "changed_context": []any{"learned managed wake constraint"}, + }}), + }} + nudgeChanged := DeriveEventEnvelopes(Request{Principal: "codex-a@project", Lifecycle: "nudge", RenderIntent: IntentTeamworkEvents}, changed, now) + if _, ok := eventByType(nudgeChanged, DerivedEventProfileUpdateRequested); !ok { + t.Fatalf("nudge with structured changed_context should render profile cue: %+v", nudgeChanged) + } +} + +func TestDerivedCueTextAvoidsForcedActionWording(t *testing.T) { + now := mustTime(t, "2026-06-24T10:00:00Z") + proj := view.View{Ref: "proj_cues", Digest: "digest_cues", Content: []view.ResourceContent{ + content("teamwork_signal", "project", []any{map[string]any{"id": "sig1", "statement": "Need teammate context"}}), + content("assignment", "project", []any{map[string]any{ + "id": "asg-exp", "actor": "codex-a@project", "assignee": "codex-b@project", + "scope": "review overdue work", "expected_work": "review overdue work", + "ttl": "30m", "created_at": "2026-06-24T09:00:00Z", + }}), + }} + body := PresentEventEnvelopes(DeriveEventEnvelopes(Request{Principal: "codex-a@project", Lifecycle: "remind", RenderIntent: IntentTeamworkEvents}, proj, now)) + for _, forced := range []string{"Update your agent_profile", "Decide whether", "Start a new act", "emit progress_digest"} { + if strings.Contains(body, forced) { + t.Fatalf("derived cue body should not force action with %q:\n%s", forced, body) + } + } +} + func TestRenderPresentationExpiredOnlyForOriginator(t *testing.T) { now := mustTime(t, "2026-06-24T10:00:00Z") proj := view.View{Ref: "proj_expired", Digest: "digest_expired", Content: []view.ResourceContent{ @@ -266,6 +324,15 @@ func bytesTrimSpace(in []byte) []byte { return []byte(strings.TrimSpace(string(in))) } +func eventByType(events []eventmodel.EventEnvelope, eventType string) (eventmodel.EventEnvelope, bool) { + for _, event := range events { + if event.Event.Type == eventType { + return event, true + } + } + return eventmodel.EventEnvelope{}, false +} + func content(kind, id string, items []any) view.ResourceContent { return contentWithFields(kind, id, map[string]any{"items": items}) } diff --git a/harness/internal/mnemond/presentation/teamwork_presenter.go b/harness/internal/mnemond/presentation/teamwork_presenter.go index 1d9f438a..b7b27dce 100644 --- a/harness/internal/mnemond/presentation/teamwork_presenter.go +++ b/harness/internal/mnemond/presentation/teamwork_presenter.go @@ -60,12 +60,12 @@ func DeriveEventEnvelopes(req Request, proj view.View, now time.Time) []eventmod events = append(events, eventmodel.DerivedEnvelope(model, derivedAt, expiresAt, presentationHintForDerivedEventType(eventType), suggested)) } - if profileStaleOrMissing(items["agent_profile"], principal) { + if shouldRenderProfileCue(req, items, principal, now) { appendDerived( DerivedEventProfileUpdateRequested, "agent_profile/project", nil, - "Update your agent_profile if your focus, availability, or context advantages changed.", + "Your agent_profile is missing or stale. If your focus, availability, context advantages, or active scopes changed, you may record an agent_profile update.", []string{"agent_profile.write_candidate.observed"}, ) } @@ -81,7 +81,7 @@ func DeriveEventEnvelopes(req Request, proj view.View, now time.Time) []eventmod DerivedEventTeamworkSignalOpen, subject, []string{subject}, - fmt.Sprintf("Teamwork signal is open: %s. Decide whether to self-assign or assign a suited teammate.", statement), + fmt.Sprintf("Teamwork signal is open: %s. Assignment or self-assignment may be useful when you choose to act.", statement), []string{"assignment.write_candidate.observed"}, ) } @@ -108,7 +108,7 @@ func DeriveEventEnvelopes(req Request, proj view.View, now time.Time) []eventmod DerivedEventAssignmentExpired, subject, []string{subject}, - fmt.Sprintf("Assignment %s expired without progress: %s. Start a new act: renew, reassign, split, close, or escalate.", id, scope), + fmt.Sprintf("Assignment %s expired without progress: %s. Available follow-up options include renew, reassign, split, close, or escalate.", id, scope), []string{"assignment.write_candidate.observed", "teamwork_signal.write_candidate.observed"}, ) case owner == principal && len(linked) > 0: @@ -131,7 +131,7 @@ func DeriveEventEnvelopes(req Request, proj view.View, now time.Time) []eventmod DerivedEventAssignmentFeedbackNeeded, subject, []string{subject}, - fmt.Sprintf("When you have progress or a blocker for assignment %s, emit progress_digest with assignment_ref=%s.", id, id), + fmt.Sprintf("Progress or blocker feedback for assignment %s can be recorded as progress_digest with assignment_ref=%s when useful.", id, id), []string{"progress_digest.write_candidate.observed"}, ) } @@ -185,16 +185,83 @@ func teamworkItems(proj view.View) map[string][]map[string]any { return out } -func profileStaleOrMissing(profiles []map[string]any, principal string) bool { +func shouldRenderProfileCue(req Request, items map[string][]map[string]any, principal string, now time.Time) bool { + if !profileStaleOrMissing(items["agent_profile"], principal, now) { + return false + } + switch req.RenderIntent { + case IntentProfileEvents: + return true + case IntentTeamworkEvents: + switch strings.TrimSpace(req.Lifecycle) { + case "prime", "compact": + return true + case "remind": + return profileRelevantForCollaboration(items, principal, now) + case "nudge": + return profileRelevantForRecentChange(items, principal) + default: + return false + } + default: + return false + } +} + +func profileStaleOrMissing(profiles []map[string]any, principal string, now time.Time) bool { for _, p := range profiles { if itemString(p, "actor") != principal { continue } - return itemString(p, "freshness") == "stale" + return itemString(p, "freshness") == "stale" || profileTTLExpired(p, now) } return true } +func profileTTLExpired(profile map[string]any, now time.Time) bool { + if now.IsZero() { + return false + } + created, err := time.Parse(time.RFC3339, itemString(profile, "created_at")) + if err != nil { + return false + } + ttl, err := time.ParseDuration(itemString(profile, "ttl")) + if err != nil || ttl <= 0 { + return false + } + return now.After(created.Add(ttl)) +} + +func profileRelevantForCollaboration(items map[string][]map[string]any, principal string, now time.Time) bool { + for _, signal := range items["teamwork_signal"] { + if itemString(signal, "statement") != "" { + return true + } + } + for _, assignment := range items["assignment"] { + if itemString(assignment, "actor") != principal { + continue + } + if assignmentExpired(assignment, now) { + return true + } + } + return false +} + +func profileRelevantForRecentChange(items map[string][]map[string]any, principal string) bool { + for _, progress := range items["progress_digest"] { + if itemString(progress, "actor") != principal { + continue + } + if itemString(progress, "blocker") != "" || len(itemStringList(progress, "changed_context")) > 0 { + return true + } + } + return false +} + func assignmentExpired(item map[string]any, now time.Time) bool { created, err := time.Parse(time.RFC3339, itemString(item, "created_at")) if err != nil { diff --git a/harness/internal/runtime/bridge.go b/harness/internal/runtime/bridge.go index 38dfd1a2..d6c5c43d 100644 --- a/harness/internal/runtime/bridge.go +++ b/harness/internal/runtime/bridge.go @@ -29,22 +29,30 @@ func NewBridge(newID, now func() string) *Bridge { return &Bridge{newID: newID, // Stamp turns intent into a trusted *.proposed event, OR returns an error if any proposed write targets a // ref outside the actor's DISPATCHED SCOPE (write-scope, R11 — the kernel's authz is actor/kind only). -// Trusted fields come from the binding (write identity), the dispatched presentation view (read-set + provenance), +// Trusted fields come from the binding (write identity), the dispatched presentation view (scope + provenance), // and the trigger (correlation + lineage) — NEVER from the intent payload, even if a hostile callback stuffs -// "actor"/"based_on" into it (R1/R2). Only Payload (the write set) rides through proposer-controlled; the -// kernel validates it. An empty/undecodable write set PASSES the bridge (the kernel rejects it as a -// malformed/empty op, preserving the audit trail); only a DECODED, out-of-scope write is blocked here. +// "actor"/"based_on" into it (R1/R2). The bridge uses the full dispatched scope for ref-level authorization, +// but stamps the proposal read-set from the written refs only; generic append-item rules read the target +// resource they update, not every resource visible to the actor. Only Payload (the write set) rides through +// proposer-controlled; the kernel validates it. An empty/undecodable write set PASSES the bridge (the kernel +// rejects it as a malformed/empty op, preserving the audit trail); only a DECODED, out-of-scope write is +// blocked here. func (br *Bridge) Stamp(b ResolvedBinding, dispatchedOn view.View, trigger contract.Event, intent contract.ProposedEvent) (contract.Event, error) { scope := make(map[contract.ResourceRef]bool, len(dispatchedOn.Resources)) + versions := make(map[contract.ResourceRef]contract.Version, len(dispatchedOn.Resources)) refs := make([]contract.ResourceRef, 0, len(dispatchedOn.Resources)) for _, rv := range dispatchedOn.Resources { scope[rv.Ref] = true + versions[rv.Ref] = rv.Version refs = append(refs, rv.Ref) } - for _, w := range decodeWrites(intent.Payload) { + writes := decodeWrites(intent.Payload) + readSet := make([]contract.ResourceVersion, 0, len(writes)) + for _, w := range writes { if !scope[w.Ref] { return contract.Event{}, fmt.Errorf("proposal writes %s/%s outside actor %q dispatched scope", w.Ref.Kind, w.Ref.ID, b.Actor) } + readSet = append(readSet, contract.ResourceVersion{Ref: w.Ref, Version: versions[w.Ref]}) } corr := trigger.CorrelationID if corr == "" { @@ -57,12 +65,12 @@ func (br *Bridge) Stamp(b ResolvedBinding, dispatchedOn view.View, trigger contr Type: b.Emits, // authorized type from the binding, not the intent's claim Actor: b.Actor, // TRUSTED write identity ResourceRefs: refs, - BasedOn: dispatchedOn.Resources, // TRUSTED read-set - PresentationViewRef: dispatchedOn.Ref, // provenance - ContextDigest: dispatchedOn.Digest, // provenance - CorrelationID: corr, // TRUSTED: inherited or minted - CausedBy: trigger.ID, // lineage - Payload: intent.Payload, // proposer-controlled write set (kernel-validated) + BasedOn: readSet, // TRUSTED rule read-set, narrowed to written refs + PresentationViewRef: dispatchedOn.Ref, // provenance + ContextDigest: dispatchedOn.Digest, // provenance + CorrelationID: corr, // TRUSTED: inherited or minted + CausedBy: trigger.ID, // lineage + Payload: intent.Payload, // proposer-controlled write set (kernel-validated) }, nil } diff --git a/harness/internal/runtime/bridge_test.go b/harness/internal/runtime/bridge_test.go index 7361d4e9..c1e4509a 100644 --- a/harness/internal/runtime/bridge_test.go +++ b/harness/internal/runtime/bridge_test.go @@ -19,7 +19,10 @@ func TestStampUsesTrustedSourcesNotPayload(t *testing.T) { br := newBridge() b := ResolvedBinding{Actor: "agent", Emits: "memory.write.proposed"} proj := view.View{Ref: "proj_abc", Digest: "abc", - Resources: []contract.ResourceVersion{{Ref: contract.ResourceRef{Kind: "memory", ID: "m1"}, Version: 3}}} + Resources: []contract.ResourceVersion{ + {Ref: contract.ResourceRef{Kind: "memory", ID: "m1"}, Version: 3}, + {Ref: contract.ResourceRef{Kind: "goal", ID: "g1"}, Version: 9}, + }} trigger := contract.Event{ID: "ev-trigger", Type: "memory.observed", CorrelationID: "corr-1"} // hostile intent tries to escalate identity / forge a read-set via payload; the write itself is in-scope: intent := contract.ProposedEvent{Type: "memory.write.proposed", Payload: map[string]any{ @@ -33,8 +36,11 @@ func TestStampUsesTrustedSourcesNotPayload(t *testing.T) { if ev.Actor != "agent" { t.Fatalf("Actor must come from binding, not payload; got %q", ev.Actor) } - if len(ev.BasedOn) != 1 || ev.BasedOn[0].Version != 3 { - t.Fatalf("BasedOn must be the dispatched presentation view's read-set; got %+v", ev.BasedOn) + if len(ev.ResourceRefs) != 2 { + t.Fatalf("ResourceRefs must retain the dispatched scope, got %+v", ev.ResourceRefs) + } + if len(ev.BasedOn) != 1 || ev.BasedOn[0].Ref != (contract.ResourceRef{Kind: "memory", ID: "m1"}) || ev.BasedOn[0].Version != 3 { + t.Fatalf("BasedOn must be narrowed to written refs from the dispatched view; got %+v", ev.BasedOn) } if ev.PresentationViewRef != "proj_abc" || ev.ContextDigest != "abc" { t.Fatalf("provenance must come from the presentation view; got ref=%q digest=%q", ev.PresentationViewRef, ev.ContextDigest)