mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 17:10:43 -10:00
feat(studio): an approval offers the answers this host will actually honour
The card had three answers and one of them lied. "Do not ask again" sent a grant that dies with the session — the sealed line under it even said so, in smaller type — and the answer that writes a rule down, which the chat TUI has offered for as long as it has had a prompt, was not reachable from this window at all. Operating one application through nine calls meant nine prompts and no way to say "this application, for this session". The kernel now says with each request which answers it will act on: a session grant, and writing the answer down. Both are derived from the call — a fresh decision takes neither unless its tool allows a session grant, and a controller with nowhere to write a rule does not offer to write one — so a window cannot promise what the host would drop. The card draws exactly those: allow once, allow for this session, do not ask again, refuse. Verified in the real Studio: granting the session for com.apple.calculator stopped the per-call prompts for it, and the two prompts that followed were the ones the grant does not cover — a different subject, and a shell command.
This commit is contained in:
1 parent
634e9842ff
commit
e54381d4f2
14 files changed
+168
-30
No files matched your search
@@ -289,6 +289,7 @@ export const EN_SETTINGS: Record<string, string> = {
|
||||
"机器、工作区与会话": "Machines, workspaces and sessions",
|
||||
"{n} 个项目": "{n} projects",
|
||||
"允许这一次": "Allow once",
|
||||
"本会话都允许": "Allow for this session",
|
||||
"此类操作不再询问": "Stop asking for this kind",
|
||||
"内核已记入会话授权,不写入磁盘。": "The kernel records it for this session only; nothing is written to disk.",
|
||||
"agent 已收到拒绝,将改用其他方式或终止。": "The agent takes the refusal and either finds another way or stops.",
|
||||
|
||||
@@ -129,7 +129,10 @@ export type ApprovalMode = "ask" | "auto" | "dontAsk" | "yolo";
|
||||
// it names is a question not worth asking. Old sessions still send it.
|
||||
export type Preset = "light" | "balanced" | "delivery";
|
||||
|
||||
export type ApprovalVerdict = "once" | "always" | "deny";
|
||||
// What an answer authorises. "session" covers the calls after it until this
|
||||
// session ends; "always" also writes it down as a rule. Which of them the host
|
||||
// will honour rides on the request — see Approval.allowsSession/allowsPersist.
|
||||
export type ApprovalVerdict = "once" | "session" | "always" | "deny";
|
||||
|
||||
// Shape of GET /status as internal/serve writes it. Anything the UI wants that
|
||||
// is not here has to be added on the Go side, not invented in the client.
|
||||
|
||||
@@ -620,8 +620,10 @@ export class SsePort extends SseTheme implements AgentPort {
|
||||
return this.post("/approve", {
|
||||
id,
|
||||
allow: verdict !== "deny",
|
||||
session: verdict === "always",
|
||||
persist: false,
|
||||
// A rule written down also covers the rest of this session, so the answer
|
||||
// holds before the file does.
|
||||
session: verdict === "session" || verdict === "always",
|
||||
persist: verdict === "always",
|
||||
});
|
||||
}
|
||||
answer(id: string, answers: { questionId: string; selected: string[] }[]) {
|
||||
|
||||
@@ -214,6 +214,9 @@ export interface Approval {
|
||||
reason?: string;
|
||||
reasonCode?: string;
|
||||
fresh?: boolean;
|
||||
// Which answers beyond "once" this host will honour for this call.
|
||||
allowsSession?: boolean;
|
||||
allowsPersist?: boolean;
|
||||
kind?: "tool" | "plan" | "recovery";
|
||||
}
|
||||
|
||||
|
||||
@@ -75,12 +75,21 @@ export function ApprovalCard({ item, onApprove, onPlan }: Props) {
|
||||
disabled={!!submitting} onClick={() => void decide("once")}>
|
||||
{submitting === "once" ? t("正在提交…") : t("允许这一次")}
|
||||
</button>
|
||||
{/* 永久扩权,所以它比「这一次」安静一档:三颗同样大小挨在一起时,
|
||||
读者分不出哪颗只管这一次、哪颗管以后所有次。 */}
|
||||
<button className="btn" data-weak="" data-action="decision.tool" data-target={item.a.id} data-value="always"
|
||||
disabled={!!submitting} onClick={() => void decide("always")}>
|
||||
{t("此类操作不再询问")}
|
||||
</button>
|
||||
{/* 后两颗比「这一次」安静一档:三颗同样大小挨在一起时,读者分不出
|
||||
哪颗只管这一次、哪颗管到会话结束、哪颗写进配置。内核会说这次
|
||||
认哪几种授权,不认的就不画 —— 画出来就是承诺它做不到的事。 */}
|
||||
{item.a.allowsSession && (
|
||||
<button className="btn" data-weak="" data-action="decision.tool" data-target={item.a.id} data-value="session"
|
||||
disabled={!!submitting} onClick={() => void decide("session")}>
|
||||
{t("本会话都允许")}
|
||||
</button>
|
||||
)}
|
||||
{item.a.allowsPersist && (
|
||||
<button className="btn" data-weak="" data-action="decision.tool" data-target={item.a.id} data-value="always"
|
||||
disabled={!!submitting} onClick={() => void decide("always")}>
|
||||
{t("此类操作不再询问")}
|
||||
</button>
|
||||
)}
|
||||
<button className="btn" data-deny="" data-action="decision.tool" data-target={item.a.id} data-value="deny"
|
||||
disabled={!!submitting} onClick={() => void decide("deny")}>
|
||||
{t("拒绝")}
|
||||
@@ -89,12 +98,12 @@ export function ApprovalCard({ item, onApprove, onPlan }: Props) {
|
||||
)}
|
||||
{sealed && (
|
||||
<div className="apv-done">
|
||||
{item.verdict === "always" ? (
|
||||
{item.verdict === "session" ? (
|
||||
<><b>{t("本会话不再询问此类操作。")}</b>{t("内核已记入会话授权,不写入磁盘。")}</>
|
||||
) : item.verdict === "always" ? (
|
||||
<><b>{t("已保存为规则。")}</b>{t("已写入配置,后续会话也不再询问此类操作。")}</>
|
||||
) : item.verdict === "deny" ? (
|
||||
<><b>{t("已拒绝。")}</b>{t("agent 已收到拒绝,将改用其他方式或终止。")}</>
|
||||
) : item.verdict === "persist" ? (
|
||||
<><b>{t("已保存为规则。")}</b>{t("已写入配置,后续会话也不再询问此类操作。")}</>
|
||||
) : item.verdict === "unknown" ? (
|
||||
<><b>{t("已在其他窗口处理。")}</b>{t("请以最新运行状态为准。")}</>
|
||||
) : (
|
||||
|
||||
@@ -27,6 +27,37 @@ describe("decision cards", () => {
|
||||
expect(approve).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// An answer the host drops must not be offered: the card used to promise "do
|
||||
// not ask again" and send a grant that died with the session, and the grant
|
||||
// that actually writes a rule was not reachable from this window at all.
|
||||
it("offers the answers this call's host says it will honour, and no others", async () => {
|
||||
const card = (allows: { allowsSession?: boolean; allowsPersist?: boolean }) => {
|
||||
const approve = vi.fn(pending);
|
||||
const item = {
|
||||
t: "approval", id: "row", a: { id: "gate", tool: "computer_act", subject: "com.apple.Notes", ...allows },
|
||||
} as Extract<Item, { t: "approval" }>;
|
||||
const r = render(<ApprovalCard item={item} onApprove={approve} onPlan={vi.fn(pending)} />);
|
||||
return { approve, ...r };
|
||||
};
|
||||
const names = () => screen.getAllByRole("button").map((b) => b.textContent);
|
||||
|
||||
const fresh = card({});
|
||||
expect(names()).toEqual(["允许这一次", "拒绝"]);
|
||||
cleanup();
|
||||
|
||||
const scoped = card({ allowsSession: true });
|
||||
expect(names()).toEqual(["允许这一次", "本会话都允许", "拒绝"]);
|
||||
await userEvent.click(screen.getByRole("button", { name: "本会话都允许" }));
|
||||
expect(scoped.approve).toHaveBeenCalledWith("row", "gate", "session");
|
||||
cleanup();
|
||||
|
||||
const full = card({ allowsSession: true, allowsPersist: true });
|
||||
expect(names()).toEqual(["允许这一次", "本会话都允许", "此类操作不再询问", "拒绝"]);
|
||||
await userEvent.click(screen.getByRole("button", { name: "此类操作不再询问" }));
|
||||
expect(full.approve).toHaveBeenCalledWith("row", "gate", "always");
|
||||
void fresh;
|
||||
});
|
||||
|
||||
it("does not invent a recommendation and restores answered tab state", () => {
|
||||
const item = {
|
||||
t: "ask", id: "row", answered: [["B"]], ask: { id: "ask", questions: [
|
||||
|
||||
@@ -17,7 +17,7 @@ import (
|
||||
func barrierController(t *testing.T) *Controller {
|
||||
t.Helper()
|
||||
c := &Controller{controllerDeps: controllerDeps{
|
||||
approval: newApprovalManager(permission.Policy{}, "", 0),
|
||||
approval: newApprovalManager(permission.Policy{}, "", 0, true),
|
||||
sink: event.Discard,
|
||||
}}
|
||||
c.sessionPath = filepath.Join(testenv.TempDir(t), "s.jsonl")
|
||||
@@ -92,7 +92,7 @@ func TestInterruptedBarrierIsDerivedAndStable(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
next := &Controller{controllerDeps: controllerDeps{
|
||||
approval: newApprovalManager(permission.Policy{}, "", 0),
|
||||
approval: newApprovalManager(permission.Policy{}, "", 0, true),
|
||||
sink: event.Discard,
|
||||
}}
|
||||
next.sessionPath = c.SessionPath()
|
||||
@@ -120,7 +120,7 @@ func TestSettledBarriersProjectNothing(t *testing.T) {
|
||||
}
|
||||
c.closeBarrier("3", status)
|
||||
next := &Controller{controllerDeps: controllerDeps{
|
||||
approval: newApprovalManager(permission.Policy{}, "", 0), sink: event.Discard,
|
||||
approval: newApprovalManager(permission.Policy{}, "", 0, true), sink: event.Discard,
|
||||
}}
|
||||
next.sessionPath = c.SessionPath()
|
||||
if got := next.InterruptedAdjudications(); len(got) != 0 {
|
||||
@@ -142,7 +142,7 @@ func TestInterruptionNeverEntersTheComposedTurn(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
next := &Controller{controllerDeps: controllerDeps{
|
||||
approval: newApprovalManager(permission.Policy{}, "", 0), sink: event.Discard,
|
||||
approval: newApprovalManager(permission.Policy{}, "", 0, true), sink: event.Discard,
|
||||
}}
|
||||
next.sessionPath = c.SessionPath()
|
||||
if got := next.RequestContext(); len(got) != 1 {
|
||||
@@ -161,7 +161,7 @@ func TestInterruptionContextOffersNoHandle(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
next := &Controller{controllerDeps: controllerDeps{
|
||||
approval: newApprovalManager(permission.Policy{}, "", 0), sink: event.Discard,
|
||||
approval: newApprovalManager(permission.Policy{}, "", 0, true), sink: event.Discard,
|
||||
}}
|
||||
next.sessionPath = c.SessionPath()
|
||||
block := next.RequestContext()[0]
|
||||
@@ -280,7 +280,7 @@ func TestSupersessionIsScopedToTheTurnThatInheritedIt(t *testing.T) {
|
||||
// A fresh process reading the same disk answers the same way while that
|
||||
// turn has not committed: the retry after a crash gets the same context.
|
||||
next := &Controller{controllerDeps: controllerDeps{
|
||||
approval: newApprovalManager(permission.Policy{}, "", 0), sink: event.Discard,
|
||||
approval: newApprovalManager(permission.Policy{}, "", 0, true), sink: event.Discard,
|
||||
}}
|
||||
next.sessionPath = c.SessionPath()
|
||||
if got := next.RequestContext(); len(got) != 1 {
|
||||
|
||||
@@ -52,6 +52,10 @@ type approvalManager struct {
|
||||
// remain authoritative, matching Auto rather than YOLO semantics.
|
||||
planAutoApprove bool
|
||||
|
||||
// persists reports whether an answer can be written down as a rule at all:
|
||||
// without a place to write it, offering it would promise a file nobody has.
|
||||
persists bool
|
||||
|
||||
// promptMu serializes outstanding prompts so at most one user decision is in
|
||||
// flight. Held across the blocking wait, so it must never be taken by the
|
||||
// resolve paths (Approve/AnswerQuestion). sink.Emit also runs under it (Ask,
|
||||
@@ -64,9 +68,10 @@ type approvalManager struct {
|
||||
promptEmitMu sync.Mutex
|
||||
}
|
||||
|
||||
func newApprovalManager(policy permission.Policy, mode string, timeout time.Duration) approvalManager {
|
||||
func newApprovalManager(policy permission.Policy, mode string, timeout time.Duration, persists bool) approvalManager {
|
||||
return approvalManager{
|
||||
policy: policy,
|
||||
persists: persists,
|
||||
approvals: map[string]pendingApproval{},
|
||||
asks: map[string]pendingAsk{},
|
||||
granted: map[string]bool{},
|
||||
@@ -551,9 +556,11 @@ func (a *approvalManager) snapshotPrompts() ([]event.Approval, []event.Ask) {
|
||||
defer a.mu.Unlock()
|
||||
approvals := make([]event.Approval, 0, len(a.approvals))
|
||||
for id, p := range a.approvals {
|
||||
session, persist := ApprovalGrants(p.tool, p.fresh)
|
||||
approvals = append(approvals, event.Approval{
|
||||
ID: id, Tool: p.tool, Subject: p.subject, Reason: p.reason, ReasonCode: ExplicitApprovalCode(p.tool, p.subject),
|
||||
RawInput: append(json.RawMessage(nil), p.rawInput...), Fresh: p.fresh,
|
||||
AllowsSession: session, AllowsPersist: persist && a.persists,
|
||||
Kind: p.kind, Recovery: p.recovery,
|
||||
})
|
||||
}
|
||||
@@ -685,6 +692,18 @@ func requiresFreshApprovalTool(tool string) bool {
|
||||
return RequiresFreshHumanApprovalTool(tool)
|
||||
}
|
||||
|
||||
// ApprovalGrants says which answers beyond "once" the host will honour for this
|
||||
// call: whether a session grant covers the calls after it, and whether the
|
||||
// answer may be written down as a rule. A window that offers an answer the host
|
||||
// drops promises what it cannot keep — the label said "do not ask again" while
|
||||
// the grant died with the session.
|
||||
func ApprovalGrants(tool string, fresh bool) (session, persist bool) {
|
||||
if fresh || RequiresFreshHumanApprovalTool(tool) {
|
||||
return allowsFreshSessionGrantTool(tool), false
|
||||
}
|
||||
return true, true
|
||||
}
|
||||
|
||||
func allowsFreshSessionGrantTool(tool string) bool {
|
||||
switch tool {
|
||||
case SandboxEscapeApprovalTool, ManagedConfigWriteApprovalTool:
|
||||
|
||||
@@ -64,3 +64,58 @@ func TestComputerUseNeedsAPersonPerApplicationInAuto(t *testing.T) {
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// What a window may offer is the host's answer, not the window's guess: an
|
||||
// answer it drops would be a promise the person cannot collect.
|
||||
func TestAnApprovalSaysWhichGrantsThisHostWillHonour(t *testing.T) {
|
||||
cases := []struct {
|
||||
tool string
|
||||
fresh bool
|
||||
session, persist bool
|
||||
}{
|
||||
{tool: "computer_act"},
|
||||
{tool: "bash"},
|
||||
{tool: memoryRememberTool},
|
||||
{tool: SandboxEscapeApprovalTool},
|
||||
{tool: "browser_act", fresh: true},
|
||||
}
|
||||
want := map[string][2]bool{
|
||||
"computer_act": {true, true},
|
||||
"bash": {true, true},
|
||||
memoryRememberTool: {false, false},
|
||||
SandboxEscapeApprovalTool: {true, false},
|
||||
"browser_act": {false, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
session, persist := ApprovalGrants(tc.tool, tc.fresh)
|
||||
if got := [2]bool{session, persist}; got != want[tc.tool] {
|
||||
t.Errorf("ApprovalGrants(%q, fresh=%v) = %v, want %v", tc.tool, tc.fresh, got, want[tc.tool])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// And the request carries that answer, so a frontend renders what the host
|
||||
// will act on rather than a menu of its own.
|
||||
func TestTheApprovalRequestCarriesItsGrants(t *testing.T) {
|
||||
approvals := make(chan event.Approval, 2)
|
||||
c := New(Options{Sink: event.FuncSink(func(e event.Event) {
|
||||
if e.Kind == event.ApprovalRequest {
|
||||
approvals <- e.Approval
|
||||
}
|
||||
})})
|
||||
go func() {
|
||||
_, _, _ = gateApprover{c}.Approve(context.Background(), "computer_act", "com.apple.Notes", nil)
|
||||
}()
|
||||
select {
|
||||
case a := <-approvals:
|
||||
if !a.AllowsSession {
|
||||
t.Error("a call a session grant covers did not say so")
|
||||
}
|
||||
if a.AllowsPersist {
|
||||
t.Error("a controller with nowhere to write a rule offered to write one")
|
||||
}
|
||||
c.Approve(a.ID, false, false, false)
|
||||
case <-time.After(30 * time.Second):
|
||||
t.Fatal("no approval arrived")
|
||||
}
|
||||
}
|
||||
@@ -558,7 +558,12 @@ func (c *Controller) requestApprovalDecision(ctx context.Context, req approvalRe
|
||||
id, reply = c.approval.registerWithInput(req.tool, req.subject, req.reason, req.args)
|
||||
}
|
||||
|
||||
c.sink.Emit(c.approvalRequestEvent(event.Approval{ID: id, Tool: req.tool, Subject: req.subject, Reason: req.reason, ReasonCode: ExplicitApprovalCode(req.tool, req.subject), RawInput: append(json.RawMessage(nil), req.args...), Fresh: req.fresh}))
|
||||
session, persist := ApprovalGrants(req.tool, req.fresh)
|
||||
c.sink.Emit(c.approvalRequestEvent(event.Approval{
|
||||
ID: id, Tool: req.tool, Subject: req.subject, Reason: req.reason, ReasonCode: ExplicitApprovalCode(req.tool, req.subject),
|
||||
RawInput: append(json.RawMessage(nil), req.args...), Fresh: req.fresh,
|
||||
AllowsSession: session, AllowsPersist: persist && c.onRemember != nil,
|
||||
}))
|
||||
c.approval.promptEmitMu.Unlock()
|
||||
// The agent now needs the user's attention; a Notification hook can ping an
|
||||
// external channel (desktop notice, phone) while the run blocks on the reply.
|
||||
|
||||
@@ -20,7 +20,7 @@ func TestManagedConfigWriteApprovalIsFreshHuman(t *testing.T) {
|
||||
t.Fatal("config_write should allow explicit session grants for one repair flow")
|
||||
}
|
||||
|
||||
a := newApprovalManager(permission.Policy{}, ToolApprovalYolo, time.Minute)
|
||||
a := newApprovalManager(permission.Policy{}, ToolApprovalYolo, time.Minute, true)
|
||||
subject := "write Reasonix config: /home/u/.reasonix/config.toml"
|
||||
if a.preApprovedForDecision(ManagedConfigWriteApprovalTool, subject, nil, true) {
|
||||
t.Fatal("YOLO posture must not pre-approve a managed config write")
|
||||
@@ -46,6 +46,7 @@ func TestApprovedPlanAutoAllowsFallbackButPreservesExplicitRules(t *testing.T) {
|
||||
permission.New("ask", nil, []string{"sensitive_writer", "Edit(secret.txt)"}, []string{"denied_writer"}),
|
||||
ToolApprovalAsk,
|
||||
time.Minute,
|
||||
true,
|
||||
)
|
||||
a.setPlanAutoApprove(true)
|
||||
|
||||
|
||||
@@ -148,7 +148,7 @@ func newControllerDeps(opts Options, sink event.Sink, usageTee *goalUsageTee, ru
|
||||
ablation: opts.Ablation,
|
||||
workspaceRoot: opts.WorkspaceRoot,
|
||||
runtimeOwner: runtimeOwner,
|
||||
approval: newApprovalManager(opts.Policy, ToolApprovalAsk, opts.ApprovalTimeout),
|
||||
approval: newApprovalManager(opts.Policy, ToolApprovalAsk, opts.ApprovalTimeout, opts.OnRemember != nil),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -332,6 +332,11 @@ type Approval struct {
|
||||
// together with locations/reason instead of parsing a human title.
|
||||
RawInput json.RawMessage
|
||||
Fresh bool // current human decision required; do not offer remembered grants
|
||||
// Which answers beyond "once" this host will honour: a grant for the rest of
|
||||
// the session, and writing the answer down as a rule. A frontend that offers
|
||||
// one the host drops promises what it cannot keep.
|
||||
AllowsSession bool
|
||||
AllowsPersist bool
|
||||
// Kind classifies the approval surface: "tool" (default), "plan", or
|
||||
// "recovery". Empty means ordinary tool permission for backward compat.
|
||||
Kind string
|
||||
|
||||
@@ -141,6 +141,7 @@ func ToWire(e event.Event) Event {
|
||||
w.Approval = &Approval{
|
||||
ID: e.Approval.ID, Tool: e.Approval.Tool, Subject: e.Approval.Subject,
|
||||
Reason: e.Approval.Reason, ReasonCode: e.Approval.ReasonCode, Fresh: e.Approval.Fresh, Kind: e.Approval.Kind,
|
||||
AllowsSession: e.Approval.AllowsSession, AllowsPersist: e.Approval.AllowsPersist,
|
||||
}
|
||||
if e.Approval.Recovery != nil {
|
||||
r := e.Approval.Recovery
|
||||
@@ -581,14 +582,17 @@ type CacheDiagnostics struct {
|
||||
|
||||
// Approval is the JSON form of an event.Approval.
|
||||
type Approval struct {
|
||||
ID string `json:"id"`
|
||||
Tool string `json:"tool"`
|
||||
Subject string `json:"subject" externalizable:"true"`
|
||||
Reason string `json:"reason,omitempty" externalizable:"true"`
|
||||
ReasonCode string `json:"reasonCode,omitempty"`
|
||||
Fresh bool `json:"fresh,omitempty"`
|
||||
Kind string `json:"kind,omitempty"` // tool | plan | recovery
|
||||
Recovery *RecoveryApproval `json:"recovery,omitempty"`
|
||||
ID string `json:"id"`
|
||||
Tool string `json:"tool"`
|
||||
Subject string `json:"subject" externalizable:"true"`
|
||||
Reason string `json:"reason,omitempty" externalizable:"true"`
|
||||
ReasonCode string `json:"reasonCode,omitempty"`
|
||||
Fresh bool `json:"fresh,omitempty"`
|
||||
// Which answers beyond "once" the host will honour for this call.
|
||||
AllowsSession bool `json:"allowsSession,omitempty"`
|
||||
AllowsPersist bool `json:"allowsPersist,omitempty"`
|
||||
Kind string `json:"kind,omitempty"` // tool | plan | recovery
|
||||
Recovery *RecoveryApproval `json:"recovery,omitempty"`
|
||||
}
|
||||
|
||||
// RecoveryApproval is the JSON form of an event.RecoveryApproval.
|
||||
|
||||
Reference in new issue
Block a user