mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
fix(composition): refuse project-controlled launch overrides
Problem: selecting a repository preset could change an already authorized MCP server's package arguments or destination and reuse its executable authority or credentials. The same provenance issue applies to extension args and env. Root cause: composition parameter validation checked shape and credential references but did not distinguish repository declarations from personal ones. Pinning an executable does not authorize the program selected by its arguments. Fix: enforce source authority in the shared contract. Project MCP/extension parameters allow only supported timeouts. Validate declarations, imports, old revisions, and both definition/resolved halves of retained executable bindings. Keep structural history validation separate so legacy unsafe bindings remain readable without regaining execution. Align Mock save/import, document the source table in both languages, and add the release note. Preserve Standard/Minimal, unbound defaults, persona, additional directories and exact dispatch bindings. Verification: failing-first boot tests prove changed args start a subprocess and changed URL contacts another endpoint before the fix; both now refuse before startup. User positive controls, scope/parameter table, store/revision/old-record and event-log history tests pass. Format/vet/lint/makecheck, required builtin/boot, full root, contract/store/sessionstore/serve/ACP race, Desktop/SDK Go and frontend typecheck/build pass; 671 frontend files / 5472 tests pass. Local review/security review recorded; maintainer approval and new-head hosted CI remain separate requirements. The full boot race invocation reached its existing aggregate 10-minute limit as another test started; disjoint test-list groups are used to finish the whole inventory without raising that timeout or skipping tests.
This commit is contained in:
1 parent
f975d35816
commit
78e164b54c
13 files changed
+367
-6
No files matched your search
@@ -2,6 +2,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { MockHub } from "./mock_hub";
|
||||
import { builtinCompositions } from "./composition";
|
||||
import { MockCompositions } from "./mock_composition";
|
||||
|
||||
describe("remote composition ownership", () => {
|
||||
it("uses each remote host's own catalog and selected composition", async () => {
|
||||
@@ -18,3 +19,27 @@ describe("remote composition ownership", () => {
|
||||
expect(sibling.agentComposition?.id).toBe(custom.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe("project composition authority", () => {
|
||||
it.each([
|
||||
["mcp", {args:["other-package"]}],
|
||||
["mcp", {url:"https://other.invalid/mcp"}],
|
||||
["mcp", {env:{PATH:"${OTHER_PATH}"}}],
|
||||
["mcp", {headers:{Authorization:"${OTHER_TOKEN}"}}],
|
||||
["extensions", {args:["other-script"]}],
|
||||
["extensions", {env:{NODE_OPTIONS:"${OTHER_OPTIONS}"}}],
|
||||
] as const)("rejects project %s launch overrides on save and import", async (kind, parameters) => {
|
||||
const presets = new MockCompositions();
|
||||
const definition = {...builtinCompositions()[0],id:"project:launch",[kind]:{mode:"select",members:[{id:"authorized",parameters}]}};
|
||||
await expect(presets.saveAgentPreset(definition,"")).rejects.toMatchObject({reason:{code:"agent_composition.invalid"}});
|
||||
await expect(presets.importAgentPreset(definition)).rejects.toMatchObject({reason:{code:"agent_composition.invalid"}});
|
||||
await expect(presets.saveAgentPreset({...definition,id:"user:launch"},"")).resolves.toMatchObject({definition:{id:"user:launch"}});
|
||||
});
|
||||
|
||||
it("allows project timeout adjustments", async () => {
|
||||
const presets = new MockCompositions();
|
||||
const definition = {...builtinCompositions()[0],id:"project:timeouts",mcp:{mode:"select" as const,members:[{id:"authorized",parameters:{callTimeoutSeconds:5,startupTimeoutSeconds:10}}]}};
|
||||
await expect(presets.saveAgentPreset(definition,"")).resolves.toMatchObject({definition});
|
||||
await expect(presets.importAgentPreset(definition)).resolves.toMatchObject({definition});
|
||||
});
|
||||
});
|
||||
@@ -17,12 +17,19 @@ export class MockCompositions {
|
||||
}
|
||||
private summary(v:AgentPresetVersion):CompositionSummary { return {id:v.definition.id,name:v.definition.name,revision:v.revision,digest:`mock-${v.revision}`,started:false}; }
|
||||
private fail(status:number,code:string):never { throw new HttpError(status,"",{code:`agent_composition.${code}`}); }
|
||||
private validateAuthority(definition:AgentComposition) {
|
||||
if (!definition.id.startsWith("project:")) return;
|
||||
for (const kind of ["mcp","extensions"] as const) for (const member of definition[kind]?.members ?? []) {
|
||||
if (Object.entries(member.parameters ?? {}).some(([key,value])=>["args","url","env","headers"].includes(key.toLowerCase()) && value!=null)) this.fail(400,"invalid");
|
||||
}
|
||||
}
|
||||
async agentPresets() { return structuredClone(this.catalog); }
|
||||
async saveAgentPreset(definition: AgentComposition, expectedRevision: string): Promise<AgentPresetVersion> {
|
||||
if (definition.id.startsWith("builtin:")) this.fail(403,"read_only");
|
||||
const prior=this.catalog.presets.find((v)=>v.definition.id===definition.id);
|
||||
if ((prior?.revision ?? "")!==expectedRevision) this.fail(409,"conflict");
|
||||
if (!/^(user|project):[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$/.test(definition.id) || !definition.name.trim()) this.fail(400,"invalid");
|
||||
this.validateAuthority(definition);
|
||||
const version={definition:structuredClone(definition),revision:`mock-${++this.library.revision}`};
|
||||
const items=definition.id.startsWith("project:") ? this.library.projects.get(this.workspace) ?? [] : this.library.user;
|
||||
const next=[...items.filter((v)=>v.definition.id!==definition.id),version];
|
||||
@@ -48,6 +55,7 @@ export class MockCompositions {
|
||||
}
|
||||
async importAgentPreset(content:AgentComposition) {
|
||||
if (content.schemaVersion!==1 || !content.name || !content.persona) this.fail(400,"invalid");
|
||||
this.validateAuthority(content);
|
||||
return {definition:structuredClone(content),diagnostics:[]};
|
||||
}
|
||||
async agentComposition() { return structuredClone(this.current); }
|
||||
|
||||
@@ -91,6 +91,36 @@ Parameter names are
|
||||
validated. Environment/header overrides require `${NAME}` references.
|
||||
Presets cannot replace an installed extension's executable declaration.
|
||||
|
||||
## Definition sources and authority
|
||||
|
||||
Selecting a definition never grants permission. Built-ins are shipped, read-only
|
||||
templates; user definitions are explicitly authored in personal state; project
|
||||
definitions are untrusted repository files, even in a trusted workspace.
|
||||
|
||||
| Value | `builtin:` | `user:` | `project:` |
|
||||
| --- | --- | --- | --- |
|
||||
| Persona and tool/module selection | Shipped choices | User choices | Repository choices, only within existing user authorization |
|
||||
| MCP `args`, `url`, `env`, `headers` | Shipped values only | Allowed; credential values remain references | Refused, including empty array/map overrides |
|
||||
| MCP call/startup timeouts | Supported | Supported | Supported |
|
||||
| Extension `args`, `env` | Shipped values only | Allowed; environment values remain references | Refused |
|
||||
| Extension timeout | Supported | Supported | Supported |
|
||||
| Extension executable, hook/command code or module installation | No new authority | Existing authorization still required | Cannot replace a launch or approve/install a program |
|
||||
|
||||
The current built-ins contain no MCP or extension overrides. Tools, skills,
|
||||
hooks and commands do not accept member parameters in any scope.
|
||||
|
||||
Project definitions can select only resources already authorized by the user,
|
||||
never widen capabilities or change a launch. Hook project-program approval and
|
||||
its revocation checks still apply; selecting a preset is not an approval card.
|
||||
|
||||
Declaration reads, saves, imports, old revisions and executable binding restore
|
||||
apply the same source policy before starting a module.
|
||||
|
||||
A previously retained project binding containing launch overrides remains
|
||||
readable as history but cannot execute, even with an old local authorization
|
||||
record. It is not silently rewritten. Valid timeout-only project bindings
|
||||
retain their original version.
|
||||
|
||||
Inherited choices resolve once at binding. Retained skill and hook files and
|
||||
extension code do not follow subsequent source edits.
|
||||
|
||||
|
||||
@@ -64,6 +64,28 @@ Studio 的「Agent 预设」把人设、工具、技能、MCP、扩展、Hook
|
||||
参数名会校验;env/headers 覆盖值只允许 `${NAME}` 引用。
|
||||
预设不能替换已安装扩展的可执行入口。
|
||||
|
||||
## 声明来源与权限
|
||||
|
||||
选择预设不会授予权限。内置声明是随程序发布的只读模板;用户声明由用户在个人
|
||||
状态中明确编写;项目声明始终是仓库提供的不可信文件,信任工作区也不改变此边界。
|
||||
|
||||
| 内容 | `builtin:` | `user:` | `project:` |
|
||||
| --- | --- | --- | --- |
|
||||
| 人设及工具、模块选择 | 随程序发布 | 用户选择 | 仓库选择,但只能使用用户已授权的资源 |
|
||||
| MCP `args`、`url`、`env`、`headers` | 仅发布内容 | 允许;凭据值仍须使用引用 | 拒绝,包括空数组或空映射覆盖 |
|
||||
| MCP 调用、启动超时 | 支持 | 支持 | 支持 |
|
||||
| 扩展 `args`、`env` | 仅发布内容 | 允许;环境值仍须使用引用 | 拒绝 |
|
||||
| 扩展超时 | 支持 | 支持 | 支持 |
|
||||
| 扩展可执行入口、Hook/命令代码或模块安装 | 不新增权限 | 仍需已有授权 | 不能替换启动方式、批准或安装程序 |
|
||||
|
||||
当前内置项没有 MCP 或扩展参数覆盖。工具、技能、Hook 和命令在所有来源下均不
|
||||
接受成员参数。项目预设只能选择用户已授权的资源,不能扩大能力或改写启动参数。
|
||||
Hook 的项目程序授权与撤销检查继续生效;选择预设不等于批准程序。
|
||||
|
||||
声明读取、保存、导入、旧版本和执行绑定恢复会在模块启动前应用同一来源规则。
|
||||
此前保存的项目绑定若含启动参数覆盖,历史仍可读取,但不能继续执行,即使本机
|
||||
保留了旧授权记录;不会静默改写为其他组合。合法的仅超时项目绑定继续保留原版本。
|
||||
|
||||
继承项只在绑定时解析一次。技能、Hook 和扩展代码使用保留副本,后续源文件
|
||||
修改不会改写旧版本。凭据继续由原有凭据或配置机制提供。MCP 连接与进程状态
|
||||
按会话隔离,扩展使用独立运行副本。原有信任、撤销、授权和沙箱规则继续生效。
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
package boot
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/contract/agentcomposition"
|
||||
"reasonix/internal/ext/plugin"
|
||||
"reasonix/internal/state/agentpreset"
|
||||
)
|
||||
|
||||
func TestProjectCompositionCannotChangeAnAuthorizedMCPLaunch(t *testing.T) {
|
||||
for _, kind := range []string{"args", "url"} {
|
||||
t.Run(kind, func(t *testing.T) {
|
||||
home, root := compositionTestWorkspace(t, "openai")
|
||||
marker := filepath.Join(root, "mcp-started")
|
||||
var requests atomic.Int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
requests.Add(1)
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
}))
|
||||
defer server.Close()
|
||||
executable, err := os.Executable()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
spec := plugin.Spec{Name: "authorized", Type: "stdio", Command: executable, Args: []string{"-test.run=^TestProjectCompositionMCPStartProbe$", "-test.v"}, Env: map[string]string{"COMPOSITION_MCP_PROBE_MARKER": marker}, Authorized: true}
|
||||
params := map[string]any{"args": []string{"-test.run=^TestProjectCompositionMCPStartProbe$"}}
|
||||
if kind == "url" {
|
||||
spec = plugin.Spec{Name: "authorized", Type: "http", URL: server.URL + "/original", Authorized: true}
|
||||
params = map[string]any{"url": server.URL + "/redirect"}
|
||||
}
|
||||
raw, err := json.Marshal(params)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, scope := range []string{"project", "user"} {
|
||||
d := agentcomposition.Builtins()[0]
|
||||
d.ID = scope + ":changed-launch"
|
||||
d.MCP = agentcomposition.Selection{Mode: agentcomposition.Select, Members: []agentcomposition.Module{{ID: spec.Name, Parameters: raw}}}
|
||||
if scope == "project" {
|
||||
data, err := json.Marshal(d)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writeFile(t, root, ".reasonix/agent-presets/definitions/changed-launch.json", string(data))
|
||||
} else if _, err := agentpreset.New(home, root).Save(d, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
built, err := BuildRuntime(t.Context(), Options{Home: home, WorkspaceRoot: root, AgentComposition: d.ID, ExtraPlugins: []plugin.Spec{spec}, resolvedShell: pinnedEffectShell()})
|
||||
if built != nil {
|
||||
built.Controller.Close()
|
||||
}
|
||||
_, markerErr := os.Stat(marker)
|
||||
started := requests.Load() > 0 || markerErr == nil
|
||||
if scope == "project" {
|
||||
if !errors.Is(err, agentcomposition.ErrInvalid) || started {
|
||||
t.Fatalf("project %s override reached an authorized launch: err=%v started=%t", kind, err, started)
|
||||
}
|
||||
} else if errors.Is(err, agentcomposition.ErrInvalid) || !started {
|
||||
t.Fatalf("positive control never exercised the authorized launch: %v", err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectCompositionMCPStartProbe(t *testing.T) {
|
||||
path := os.Getenv("COMPOSITION_MCP_PROBE_MARKER")
|
||||
if path == "" {
|
||||
return
|
||||
}
|
||||
if err := os.WriteFile(path, []byte("started"), 0o600); err != nil {
|
||||
os.Exit(2)
|
||||
}
|
||||
os.Exit(0)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package agentcomposition
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// History decoding checks structure; executable declarations also obey source authority.
|
||||
func (d Definition) ValidateForExecution() error {
|
||||
if err := d.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if !strings.HasPrefix(d.ID, "project:") {
|
||||
return nil
|
||||
}
|
||||
for _, selection := range []Selection{d.MCP, d.Extensions} {
|
||||
for _, module := range selection.Members {
|
||||
p, err := ParseParameters(module.Parameters)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if p.Args != nil || p.URL != nil || p.Env != nil || p.Headers != nil {
|
||||
return fmt.Errorf("%w: project module %s may override only timeouts", ErrInvalid, module.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *Binding) ValidateForExecution() error {
|
||||
if err := b.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := b.Version.Definition.ValidateForExecution(); err != nil {
|
||||
return err
|
||||
}
|
||||
return b.Resolved.ValidateForExecution()
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package agentcomposition
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDefinitionAuthorityBySourceAndModule(t *testing.T) {
|
||||
cases := []struct {
|
||||
module, raw string
|
||||
projectOK bool
|
||||
}{
|
||||
{"mcp", `{}`, true},
|
||||
{"mcp", `{"callTimeoutSeconds":5,"startupTimeoutSeconds":10}`, true},
|
||||
{"mcp", `{"args":["other-package"]}`, false},
|
||||
{"mcp", `{"args":[]}`, false},
|
||||
{"mcp", `{"url":"https://other.invalid/mcp"}`, false},
|
||||
{"mcp", `{"env":{"PATH":"${OTHER_PATH}"}}`, false},
|
||||
{"mcp", `{"env":{}}`, false},
|
||||
{"mcp", `{"headers":{"Authorization":"${OTHER_TOKEN}"}}`, false},
|
||||
{"mcp", `{"headers":{}}`, false},
|
||||
{"extensions", `{"timeoutMillis":5000}`, true},
|
||||
{"extensions", `{"args":["other-script"]}`, false},
|
||||
{"extensions", `{"env":{"NODE_OPTIONS":"${OTHER_OPTIONS}"}}`, false},
|
||||
}
|
||||
for _, scope := range []string{"builtin", "user", "project"} {
|
||||
for _, tc := range cases {
|
||||
t.Run(scope+"/"+tc.module+"/"+tc.raw, func(t *testing.T) {
|
||||
d := Builtins()[0]
|
||||
d.ID = scope + ":test"
|
||||
selection := Selection{Mode: Select, Members: []Module{{ID: "authorized", Parameters: json.RawMessage(tc.raw)}}}
|
||||
if tc.module == "mcp" {
|
||||
d.MCP = selection
|
||||
} else {
|
||||
d.Extensions = selection
|
||||
}
|
||||
err := d.ValidateForExecution()
|
||||
data, marshalErr := json.Marshal(d)
|
||||
if marshalErr != nil {
|
||||
t.Fatal(marshalErr)
|
||||
}
|
||||
_, decodeErr := Decode(data)
|
||||
if scope == "project" && !tc.projectOK {
|
||||
if !errors.Is(err, ErrInvalid) || !errors.Is(decodeErr, ErrInvalid) {
|
||||
t.Fatalf("repository changed a launch: validate=%v decode=%v", err, decodeErr)
|
||||
}
|
||||
} else if err != nil || decodeErr != nil {
|
||||
t.Fatalf("validate=%v decode=%v", err, decodeErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -168,7 +168,7 @@ func Decode(data []byte) (Definition, error) {
|
||||
if err := dec.Decode(new(any)); err != io.EOF {
|
||||
return d, fmt.Errorf("%w: trailing data", ErrInvalid)
|
||||
}
|
||||
return d, d.Validate()
|
||||
return d, d.ValidateForExecution()
|
||||
}
|
||||
|
||||
func Revision(d Definition) string { return Digest(d) }
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
|
||||
// RetainBinding records a binding assembled from locally authorized sources.
|
||||
func (s *Store) RetainBinding(binding *agentcomposition.Binding) error {
|
||||
if err := binding.Validate(); err != nil {
|
||||
if err := binding.ValidateForExecution(); err != nil {
|
||||
return err
|
||||
}
|
||||
return writeJSON(filepath.Join(s.trustedDirectory(), "bindings", binding.Digest+".json"), binding)
|
||||
@@ -20,7 +20,7 @@ func (s *Store) RetainBinding(binding *agentcomposition.Binding) error {
|
||||
|
||||
// VerifyBinding never treats self-authenticated imported history as executable authority.
|
||||
func (s *Store) VerifyBinding(binding *agentcomposition.Binding) error {
|
||||
if err := binding.Validate(); err != nil {
|
||||
if err := binding.ValidateForExecution(); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(binding.Digest) != 64 || strings.Trim(binding.Digest, "0123456789abcdef") != "" {
|
||||
@@ -34,7 +34,7 @@ func (s *Store) VerifyBinding(binding *agentcomposition.Binding) error {
|
||||
if err := json.Unmarshal(data, &retained); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := retained.Validate(); err != nil {
|
||||
if err := retained.ValidateForExecution(); err != nil {
|
||||
return err
|
||||
}
|
||||
if retained.Digest != binding.Digest {
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package agentpreset
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/contract/agentcomposition"
|
||||
)
|
||||
|
||||
func TestProjectLaunchOverridesCannotReuseAnOldTrustedBinding(t *testing.T) {
|
||||
store := New(t.TempDir(), t.TempDir())
|
||||
d := agentcomposition.Builtins()[0]
|
||||
d.ID = "project:legacy-launch"
|
||||
d.MCP = agentcomposition.Selection{Mode: agentcomposition.Select, Members: []agentcomposition.Module{{ID: "authorized", Parameters: []byte(`{"args":["other-package"]}`)}}}
|
||||
for _, target := range []string{"declaration", "resolved"} {
|
||||
t.Run(target, func(t *testing.T) {
|
||||
version, resolved := agentcomposition.Clone(d), agentcomposition.Clone(d)
|
||||
if target == "declaration" {
|
||||
resolved.MCP.Members[0].Parameters = nil
|
||||
} else {
|
||||
version.MCP.Members[0].Parameters = nil
|
||||
}
|
||||
binding := &agentcomposition.Binding{Version: agentcomposition.Version{Definition: version, Revision: agentcomposition.Revision(version)}, Resolved: resolved, Workspace: store.Workspace}
|
||||
binding.Seal()
|
||||
if err := binding.Validate(); err != nil {
|
||||
t.Fatalf("legacy history no longer readable: %v", err)
|
||||
}
|
||||
if err := writeJSON(filepath.Join(store.trustedDirectory(), "bindings", binding.Digest+".json"), binding); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.VerifyBinding(binding); !errors.Is(err, agentcomposition.ErrInvalid) {
|
||||
t.Fatalf("legacy authorization permitted a project launch override: %v", err)
|
||||
}
|
||||
if err := store.RetainBinding(binding); !errors.Is(err, agentcomposition.ErrInvalid) {
|
||||
t.Fatalf("unsafe binding retained again: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
if _, err := store.Save(d, ""); !errors.Is(err, agentcomposition.ErrInvalid) {
|
||||
t.Fatalf("unsafe declaration saved: %v", err)
|
||||
}
|
||||
if err := writeJSON(filepath.Join(store.directory("project"), "definitions", "legacy-launch.json"), d); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := store.Get(d.ID); !errors.Is(err, agentcomposition.ErrInvalid) {
|
||||
t.Fatalf("repository file bypassed authority validation: %v", err)
|
||||
}
|
||||
version := agentcomposition.Version{Definition: d, Revision: agentcomposition.Revision(d)}
|
||||
if err := writeJSON(filepath.Join(store.directory("user"), "revisions", version.Revision+".json"), version); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := store.Version(version.Revision); !errors.Is(err, agentcomposition.ErrInvalid) {
|
||||
t.Fatalf("old revision bypassed authority validation: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -94,7 +94,7 @@ func (s *Store) Get(id string) (agentcomposition.Version, error) {
|
||||
}
|
||||
|
||||
func (s *Store) Save(d agentcomposition.Definition, expected string) (agentcomposition.Version, error) {
|
||||
if err := d.Validate(); err != nil {
|
||||
if err := d.ValidateForExecution(); err != nil {
|
||||
return agentcomposition.Version{}, err
|
||||
}
|
||||
path, err := s.declarationPath(d.ID)
|
||||
@@ -135,7 +135,7 @@ func (s *Store) Version(revision string) (agentcomposition.Version, error) {
|
||||
if v.Revision != revision || agentcomposition.Revision(v.Definition) != revision {
|
||||
return v, agentcomposition.ErrInvalid
|
||||
}
|
||||
return v, v.Definition.Validate()
|
||||
return v, v.Definition.ValidateForExecution()
|
||||
}
|
||||
|
||||
func (s *Store) Delete(id, expected string) error {
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
package sessionstore
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/contract/agentcomposition"
|
||||
"reasonix/internal/contract/provider"
|
||||
)
|
||||
|
||||
func TestLegacyProjectLaunchOverrideRemainsReadableAsHistory(t *testing.T) {
|
||||
d := agentcomposition.Builtins()[0]
|
||||
d.ID = "project:legacy-launch"
|
||||
d.MCP = agentcomposition.Selection{Mode: agentcomposition.Select, Members: []agentcomposition.Module{{ID: "authorized", Parameters: []byte(`{"url":"https://other.invalid/mcp"}`)}}}
|
||||
binding := &agentcomposition.Binding{Version: agentcomposition.Version{Definition: d, Revision: agentcomposition.Revision(d)}, Resolved: d}
|
||||
binding.Seal()
|
||||
session := NewSession("retained prompt")
|
||||
session.BindComposition(binding)
|
||||
session.Add(provider.Message{Role: provider.RoleUser, Content: "history remains visible"})
|
||||
path := filepath.Join(t.TempDir(), "legacy-project.jsonl")
|
||||
if err := session.SaveSnapshot(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
loaded, err := LoadSession(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := loaded.Composition(); got == nil || got.Digest != session.Composition().Digest || !errors.Is(got.ValidateForExecution(), agentcomposition.ErrInvalid) {
|
||||
t.Fatalf("history binding lost or made executable: %+v", got)
|
||||
}
|
||||
archive, err := ExportNativeArchive(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
imported, err := DecodeNativeArchive(archive)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := imported.Composition(); got == nil || got.Digest != loaded.Composition().Digest || !errors.Is(got.ValidateForExecution(), agentcomposition.ErrInvalid) {
|
||||
t.Fatalf("archive binding lost or made executable: %+v", got)
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
- Agent 组合选择与管理整合为输入框顶部状态行的统一菜单,展示来源和能力说明。#13613
|
||||
- 内置模式收敛为标准与精简两种,管理页采用模式卡片和独立自定义区域。精简模式保留终端、图片读取和上下文压缩,已有会话继续使用原绑定。#13613
|
||||
- 组合切换保留 ACP 额外工作目录;派发继承精确绑定,并明确拒绝跨工作区使用绑定。#13613
|
||||
- 项目预设的 MCP/扩展参数仅可调整超时,不能改写启动参数、地址或凭据环境;旧的不安全绑定保留历史但拒绝执行。#13613
|
||||
|
||||
<!-- lang:en -->
|
||||
|
||||
@@ -11,3 +12,4 @@
|
||||
- Combine composition selection and management in one menu beside the composer run status, with source and capability notes. #13613
|
||||
- Add Standard and Minimal cards above custom presets. Minimal keeps terminal, image reading and compaction; existing conversations retain their binding. #13613
|
||||
- Preserve ACP additional directories across composition switches; dispatch carries the exact binding and refuses cross-workspace bindings. #13613
|
||||
- Limit project MCP/extension parameters to timeouts; refuse launch, endpoint or credential-environment overrides, including in old executable bindings, while keeping history readable. #13613
|
||||
Reference in new issue
Block a user