diff --git a/docs/GUIDE.md b/docs/GUIDE.md index 27ed3fea17..98a408d9fd 100644 --- a/docs/GUIDE.md +++ b/docs/GUIDE.md @@ -144,7 +144,9 @@ allow = ["Bash(go test:*)"] # never prompted [peers] # enabled = false # your sessions on this machine can message each other; every message waits for that session's user to approve it; also a switch in Settings, applied at the next turn (user config only) -# dispatch = false # the model may hand a task to a session in another of your projects, via dispatch_to_project; needs enabled (user config only) +# dispatch = false # the model may hand a task to a session in another of your projects, via dispatch_to_project; needs enabled; turning it on is your consent for those sessions' tasks and replies, no card (user config only) +# max_dispatch_depth = 1 # how many sessions deep a dispatch may chain; 0 removes the ceiling (user config only) +# max_dispatched_sessions = 8 # sessions one session may keep open from its dispatches; 0 removes the ceiling (user config only) # release_projects = [] # projects that take messages from your other sessions without the card; their own approvals and sandbox still apply (user config only) [serve] diff --git a/docs/GUIDE.zh-CN.md b/docs/GUIDE.zh-CN.md index 40378fd189..233f50a50d 100644 --- a/docs/GUIDE.zh-CN.md +++ b/docs/GUIDE.zh-CN.md @@ -153,7 +153,9 @@ allow = ["Bash(go test:*)"] # 从不询问 [peers] # enabled = false # 本机你的各个会话可以互发消息;每条消息都要对方会话的用户批准;设置里也有开关,下个回合起生效(只认用户级配置) -# dispatch = false # 模型可用 dispatch_to_project 把任务交给你另一个项目里的会话;需要 enabled(只认用户级配置) +# dispatch = false # 模型可用 dispatch_to_project 把任务交给你另一个项目里的会话;需要 enabled;打开就是你对这些会话的任务和回信的总同意,不再出审批卡(只认用户级配置) +# max_dispatch_depth = 1 # 派发最多能连着开几层会话;0 表示不限(只认用户级配置) +# max_dispatched_sessions = 8 # 一个会话通过派发最多同时开着几个会话;0 表示不限(只认用户级配置) # release_projects = [] # 这些项目收到你其它会话的消息时不弹批准卡;它们自己的审批与沙盒照旧(只认用户级配置) [serve] diff --git a/internal/assembly/boot/effect_dispatch_consent_test.go b/internal/assembly/boot/effect_dispatch_consent_test.go new file mode 100644 index 0000000000..bd484381f6 --- /dev/null +++ b/internal/assembly/boot/effect_dispatch_consent_test.go @@ -0,0 +1,80 @@ +package boot + +import ( + "path/filepath" + "strings" + "testing" + "time" + + "reasonix/internal/state/peermail" +) + +func (p *peerToolProvider) sawInMessages(needle string) bool { + p.mu.Lock() + defer p.mu.Unlock() + for _, req := range p.reqs { + for _, m := range req.Messages { + if strings.Contains(m.Content, needle) { + return true + } + } + } + return false +} + +// While the user's dispatch switch is on, a task from the session that opened +// this one reaches the model with no card, framed as data from that session and +// not as something the user read; the same words from a session the host never +// paired it with, or signed with another secret, wait for the user as before. +func TestEffectADispatchedTaskReachesTheModelWithoutACardAndAStrangersDoesNot(t *testing.T) { + const parent = "s-00000000000000bb" + secret := []byte("0123456789abcdef0123456789abcdef") + rec, ctrl, peersDir, seen := buildPeerToolSession(t, true, "nobody", "hello", "dispatch = true\n") + t.Setenv("REASONIX_HOME", filepath.Dir(peersDir)) + receiver := ctrl.PeerKey() + if receiver == "" { + t.Fatal("the receiving session did not publish itself") + } + ctrl.MarkOpenedByDispatch(parent, secret, 1) + d, err := peermail.Open(peersDir) + if err != nil { + t.Fatal(err) + } + send := func(id, body string, signWith []byte) { + t.Helper() + m := peermail.Message{ID: id, SentAt: time.Now().UTC(), Body: body, + From: peermail.Party{SessionID: parent, Title: "planner", Project: "alpha"}, To: peermail.Party{SessionID: receiver}} + m.Proof = peermail.Proof(signWith, m) + if err := d.Send(m); err != nil { + t.Fatal(err) + } + } + + send("m-forged", "FORGED-TASK-WITH-ANOTHER-SECRET", []byte("another secret another secret!!!")) + ctrl.RefreshPeers() + if held := ctrl.HeldPeerMessages(); len(held) != 1 || held[0].Body != "FORGED-TASK-WITH-ANOTHER-SECRET" { + t.Fatalf("held = %+v, want the badly signed task waiting for a card", held) + } + + send("m-real", "ROTATE-THE-KEY-TASK", secret) + ctrl.RefreshPeers() + waitFor(t, "the dispatched task to reach the provider", func() bool { return rec.sawInMessages("ROTATE-THE-KEY-TASK") }) + if rec.sawInMessages("FORGED-TASK-WITH-ANOTHER-SECRET") { + t.Fatal("a task signed with another secret reached the model") + } + if !rec.sawInMessages("has not read this message") || !rec.sawInMessages("not an instruction from the user") { + t.Fatal("the dispatched task reached the model without saying the user did not read it and that it is data") + } + // The stub provider's own send_peer_message call is the only card there may be: + // nothing asked the user to let the dispatched task in. + seen.mu.Lock() + for _, card := range seen.approvals { + if card.Tool != "send_peer_message" { + t.Errorf("unexpected approval card: %+v", card) + } + } + seen.mu.Unlock() + if got := ctrl.HeldPeerMessages(); len(got) != 1 { + t.Fatalf("held = %+v, want only the badly signed one still waiting", got) + } +} diff --git a/internal/frontend/serve/dispatch.go b/internal/frontend/serve/dispatch.go index 52290d4249..5c1647b32c 100644 --- a/internal/frontend/serve/dispatch.go +++ b/internal/frontend/serve/dispatch.go @@ -67,7 +67,7 @@ func (h *Hub) dispatchTo(ctx context.Context, from *Runtime, project, text strin } sender, ok := from.Server.Controller().(*control.Controller) if !ok { - return control.DispatchResult{}, control.ErrDispatchUnavailable + return control.DispatchResult{}, control.ErrDispatchNotLocal } target := h.runtimeByID(h.dispatch.get(from.ID, root)) var secret []byte @@ -84,7 +84,7 @@ func (h *Hub) dispatchTo(ctx context.Context, from *Runtime, project, text strin } targetCtrl, ok := target.Server.Controller().(*control.Controller) if !ok { - return control.DispatchResult{}, control.ErrDispatchUnavailable + return control.DispatchResult{}, control.ErrDispatchNotLocal } key := targetCtrl.PeerKey() if key == "" { @@ -122,7 +122,7 @@ func (h *Hub) openDispatchSession(ctx context.Context, from *Runtime, sender *co ctrl, ok := target.Server.Controller().(*control.Controller) if !ok { _ = h.Close(target.ID) - return nil, control.ErrDispatchUnavailable + return nil, control.ErrDispatchNotLocal } if ctrl.WorkspaceTrust() != config.WorkspaceTrusted { _ = h.Close(target.ID) diff --git a/internal/session/control/peer_dispatch.go b/internal/session/control/peer_dispatch.go index 43c48595ea..48037d5285 100644 --- a/internal/session/control/peer_dispatch.go +++ b/internal/session/control/peer_dispatch.go @@ -14,10 +14,11 @@ import ( // Each is an identity the dispatch tool reports; none is matched by its words. var ( ErrDispatchDisabled = errors.New(`the user has not turned on "Let the model hand a task to another session" (Settings > Tools > Messages between sessions, which also needs "enabled"; [peers] dispatch in the user config)`) - ErrDispatchUnavailable = errors.New("this session cannot open sessions in other projects") + ErrDispatchUnavailable = errors.New("this session has no window to open another session in (it is a terminal or protocol session): open the project in Reasonix Studio or the web UI to dispatch from it") + ErrDispatchNotLocal = errors.New("this pane is not one of this machine's own sessions (it is driven over SSH or by another window), so it cannot open a session here") ErrDispatchUnknownProject = errors.New("no project of yours answers to that name") ErrDispatchAmbiguous = errors.New("more than one project answers to that name") - ErrDispatchNested = errors.New("a session opened by a dispatch cannot dispatch further") + ErrDispatchNested = errors.New("this session is as many dispatches deep as your ceiling allows (peers.max_dispatch_depth in the user config; 0 removes it)") ErrDispatchUntrusted = errors.New("that project is not one you have trusted") ErrDispatchFailed = errors.New("could not open a session in that project") ErrDispatchLimit = errors.New("this session already has as many dispatched sessions open as your ceiling allows (peers.max_dispatched_sessions)") diff --git a/internal/session/control/peer_mail_slash.go b/internal/session/control/peer_mail_slash.go index f163bc057a..36c179da5c 100644 --- a/internal/session/control/peer_mail_slash.go +++ b/internal/session/control/peer_mail_slash.go @@ -3,6 +3,7 @@ package control import ( "errors" "fmt" + "strconv" "strings" ) @@ -127,6 +128,7 @@ func (c *Controller) peerSettingsText() string { if s.Pending { b.WriteString(" (applies when the next turn starts)") } + fmt.Fprintf(&b, "\ndispatch ceilings: depth %s, open sessions %s (0 removes a ceiling)", strconv.Itoa(s.MaxDispatchDepth), strconv.Itoa(s.MaxDispatchedSessions)) for _, r := range s.ReleaseProjects { fmt.Fprintf(&b, "\nreleased project: %s", r.Path) } diff --git a/internal/session/control/peer_mail_tool.go b/internal/session/control/peer_mail_tool.go index 0178e49332..30f68d03b4 100644 --- a/internal/session/control/peer_mail_tool.go +++ b/internal/session/control/peer_mail_tool.go @@ -57,7 +57,7 @@ func peerErrorCode(err error) string { "peer.disabled": ErrPeersDisabled, "peer.not_found": ErrPeerNotFound, "peer.ambiguous": ErrPeerAmbiguous, "peer.self": ErrPeerSelf, "peer.refused": ErrPeerRefused, "peer.rate_limited": ErrPeerRateLimited, "peer.empty": ErrPeerEmpty, "peer.too_large": ErrPeerTooLarge, - "dispatch.disabled": ErrDispatchDisabled, "dispatch.unavailable": ErrDispatchUnavailable, "dispatch.unknown_project": ErrDispatchUnknownProject, + "dispatch.disabled": ErrDispatchDisabled, "dispatch.unavailable": ErrDispatchUnavailable, "dispatch.not_local": ErrDispatchNotLocal, "dispatch.unknown_project": ErrDispatchUnknownProject, "dispatch.ambiguous": ErrDispatchAmbiguous, "dispatch.nested": ErrDispatchNested, "dispatch.untrusted": ErrDispatchUntrusted, "dispatch.failed": ErrDispatchFailed, "dispatch.limit": ErrDispatchLimit, } { diff --git a/internal/tools/builtin/peers_dispatch.go b/internal/tools/builtin/peers_dispatch.go index d5c16a7664..89506e68f7 100644 --- a/internal/tools/builtin/peers_dispatch.go +++ b/internal/tools/builtin/peers_dispatch.go @@ -18,7 +18,7 @@ type dispatchToProject struct{ link *tool.PeerLink } func (*dispatchToProject) Name() string { return "dispatch_to_project" } func (*dispatchToProject) Description() string { - return "Hand a task to a session in another of the user's projects: name the project (its folder name or full path, as the user has it in their project list) and write what that session should do. A session is opened there if none of yours is already waiting. The message is held until that project's own user approves it (unless they released that project); only then does its model read it, labelled as coming from you, and it works under its own permissions, not yours. It is for passing a task and its context, not a way to write into that project yourself. You get the receiving session's key; it can answer you with send_peer_message. Do not include secrets." + return "Hand a task to a session in another of the user's projects: name the project (its folder name or full path, as the user has it in their project list) and write what that session should do. A session is opened there if none of yours is already waiting. The user's dispatch switch is their consent: the receiving session's model reads the task at once, labelled as coming from you, and it works under permissions no wider than yours. A project the user has not trusted is refused. It is for passing a task and its context, not a way to write into that project yourself. You get the receiving session's key; it can answer you with send_peer_message. Do not include secrets." } func (*dispatchToProject) Schema() json.RawMessage { return json.RawMessage(`{"type":"object","properties":{"project":{"type":"string","description":"The project's folder name or full path, as it appears in the user's project list"},"text":{"type":"string","description":"The task and the context the other session needs"}},"required":["project","text"],"additionalProperties":false}`) @@ -42,11 +42,11 @@ func (t *dispatchToProject) Execute(ctx context.Context, args json.RawMessage) ( } d, ok := dir.(tool.ProjectDispatcher) if !ok { - return "", errors.New("dispatch.unavailable: this session cannot open sessions in other projects") + return "", errors.New("dispatch.unavailable: this session has no window to open another session in; open the project in Reasonix Studio or the web UI to dispatch from it") } r, err := d.DispatchToProject(ctx, in.Project, in.Text) if err != nil { return "", err } - return fmt.Sprintf("Handed to a session in %q as %s (key %s). It is read only after that project's user approves it; it can answer with send_peer_message to your key.", r.Project, r.ID, r.Key), nil + return fmt.Sprintf("Handed to a session in %q as %s (key %s). It can answer you with send_peer_message to your key; its reply arrives labelled as data from that session, not as an instruction.", r.Project, r.ID, r.Key), nil } diff --git a/internal/tools/builtin/peers_dispatch_test.go b/internal/tools/builtin/peers_dispatch_test.go index 507f3ad4b0..bf2b4083f9 100644 --- a/internal/tools/builtin/peers_dispatch_test.go +++ b/internal/tools/builtin/peers_dispatch_test.go @@ -42,7 +42,7 @@ func TestDispatchToolHandsTheTaskOverAndReportsWhereItWent(t *testing.T) { if err != nil || d.asked != [2]string{"billing", "rotate the key"} { t.Fatalf("out %q err %v asked %v", out, err, d.asked) } - for _, want := range []string{"/work/billing", "m-7", "s-9", "approves"} { + for _, want := range []string{"/work/billing", "m-7", "s-9", "not as an instruction"} { if !strings.Contains(out, want) { t.Errorf("result lacks %q: %s", want, out) } diff --git a/release-notes/studio/unreleased/dispatch-open-switch.md b/release-notes/studio/unreleased/dispatch-open-switch.md new file mode 100644 index 0000000000..f72a57fbac --- /dev/null +++ b/release-notes/studio/unreleased/dispatch-open-switch.md @@ -0,0 +1,2 @@ +- 变更:打开设置里的“派发”开关就是对派发的总同意:任务和回信不再出审批卡;新会话沿用发起会话的模型、预设、审批档位和计划模式,且不会更宽。见 #13526。 +- Changed: the Dispatch switch is your consent: tasks and replies come in with no card, and the opened session keeps the sender's model, preset, approval mode and planning, never wider. See #13526.