diff --git a/docs/ISOLATION_RUNTIME_OPTIONS_SECURITY.md b/docs/ISOLATION_RUNTIME_OPTIONS_SECURITY.md new file mode 100644 index 0000000000..edd3789414 --- /dev/null +++ b/docs/ISOLATION_RUNTIME_OPTIONS_SECURITY.md @@ -0,0 +1,115 @@ +--- +owner: @SivanCola +backup: @esengine +status: active +reviewed: 2026-10-25 +--- + +# Isolated runtime options / 隔离任务运行参数 + +## Cause and scope / 根因与范围 + +The task tool dispatched `isolation: "worktree"` before ordinary task-spec +resolution. That branch forwarded the prompt and explicit model, but silently +dropped `effort`, `max_steps` and the configured subagent model. + +Its separate +kernel could therefore run with a different model/depth and without the requested +step cap. This is parameter loss, not evidence of a permission bypass. + +An adjacent shared model-resolution defect reloaded a parent-model alias from +configuration instead of preserving the actual parent entry, losing a session +effort override. Canonical references already preserved that entry. + +任务工具在普通任务规格解析前分流到 worktree,只转发提示与显式模型,遗漏 effort、 +max_steps 和已配置的子代理模型。隔离子内核因此可能使用错误模型、思考深度或步数上限。 +这属于参数丢失,不能据此声称权限绕过。相邻的模型别名解析还会重新读取配置,丢失父 +会话已生效的 effort 覆盖;规范模型引用原本会保留该覆盖。 + +## Source, owner and sink / 来源、所有者与执行端 + +- Task arguments and existing task defaults enter the same model/effort and + child-budget resolution owners used by ordinary tasks. Vision routing remains + in the existing child-runtime resolver. +- The isolated runner receives ephemeral `Run.Effort` and `Run.MaxSteps` values. + Boot resolves a `provider.Selection` through the shared subagent model/effort + validation owner, then supplies it and the cap to the unattended child's Build. + No extra provider is constructed merely to validate a selection. +- Parent aliases resolving to the same canonical model reuse the runtime parent + entry. A different model keeps its own defaults; explicit effort still wins. +- Unsupported explicit model/effort combinations remain errors. Existing + inherited-effort policy may drop an incompatible inherited setting on a + call-selected model, while a configured model/effort pair remains strict. + A custom provider resolver retains its existing selection authority. +- Best-of uses the same private unattended builder. It continues to pass its + model with no effort override or explicit cap, preserving its prior policy. + +显式参数和默认值通过普通任务已有的解析所有者,执行端将结果传入子内核 Build。 +新增字段只在内存中的 runner 调用传递,不增加额外 provider 构造。 + +父模型别名保留 +实际会话值,其他模型使用自身默认值;显式 effort 优先。不兼容显式参数继续报错, +继承值沿用既有逐模型策略,自定义 resolver 的权限不变。共享 unattended builder +的 best-of 调用继续使用原先无显式 effort/上限的策略。 + +## Authority and compatibility / 权限与兼容 + +This does not enable isolation in a session that did not offer it. Profiles, +tool restrictions, write paths, background execution and continuation/fork +arguments remain explicitly refused by this isolation path before execution. + +They are not silently removed or treated as implemented. Existing approvals, +workspace selection, sandbox policy and apply/discard behavior are unchanged. + +Only the in-process runner and private builder signatures change. Persisted +result records, config files, wire messages, public tool schemas and tool order +do not change. The Store Execute signature remains compatible. + +Zero MaxSteps +retains the existing automatic behavior; a positive explicit cap wins, otherwise +the ordinary child-budget rule derives it from the parent cap. This is a local +step cap, not a shared root resource or concurrency budget. + +不会为原本关闭的会话启用隔离。profile、工具限制、write_paths、后台和续跑/fork +仍在执行前明确拒绝,不能视为已支持或静默删除。原审批、沙箱、应用/丢弃行为不变。 + +只改进程内调用,不改持久格式、配置、wire、工具定义及顺序。零上限沿用自动行为, +显式正值优先,否则复用普通子任务预算规则;这不是根任务共享预算或并发限额。 + +## Regression boundary / 回归边界 + +Real `boot.Build` regressions observe actual provider construction and requests +through both direct task and deferred capability dispatch. + +They cover explicit +and configured models, supported/incompatible effort, parent runtime overrides, +parent aliases, explicit/derived/automatic step caps and unchanged system/tool +prefixes over the child run. Finite scripted providers bound the negative +control even when the old implementation ignores its cap. + +The predecessor owners can be compiled with the unchanged public-assembly +behavior regression. The additional private-helper unit is excluded from that +overlay because its helper did not exist before this repair; compilation errors +are not counted as behavior failures. + +真实装配测试观察 provider 构造和请求,覆盖直接与动态 capability 入口、模型与 effort +及三类步数策略,并比较子任务各回合的系统/工具前缀。反向测试保留真实装配行为测试, +只排除旧版本不存在的私有 helper 单测;编译错误不能算行为反例。 + +## Remaining work / 后续边界 + +Automatic isolation, durable Begin/running identity, background/continuation, +profile/tool/write-path support, nested/root budgets, environment preparation, +cross-process settlement, recovery journals, snapshot-bound verification and +automatic integration remain independent series work. + +Worktrees do not isolate +network services, databases, credentials, ports or all shared Git metadata. + +This author assessment is not independent security approval or native-platform +qualification, and does not resolve historical findings from the original large +PR. Native and hosted evidence must name the tested revision separately. + +自动隔离、持久运行身份、后台/续跑、profile/工具/路径支持、根预算、环境准备、 +整合日志和快照验证仍独立推进。worktree 不隔离网络、数据库、凭证、端口及全部 +Git 共享元数据。作者分析不能替代独立安全审查或原生平台验证。 diff --git a/internal/assembly/boot/bestof.go b/internal/assembly/boot/bestof.go index 7aeda05412..96ab85c65c 100644 --- a/internal/assembly/boot/bestof.go +++ b/internal/assembly/boot/bestof.go @@ -73,7 +73,7 @@ func (b *builder) addBestOf() { func candidateRunner(parent Options, sink event.Sink, approval *candidateApproval) bestof.Runner { return func(ctx context.Context, run bestof.Run) (bestof.Outcome, error) { cs := &candidateSink{parent: sink} - answer, unverified, err := runUnattended(ctx, parent, cs, approval.mode(), run.WorkspaceRoot, run.Prompt, run.Model) + answer, unverified, err := runUnattended(ctx, parent, cs, approval.mode(), run.WorkspaceRoot, run.Prompt, provider.Selection{Ref: run.Model}, 0) return bestof.Outcome{Answer: answer, Unverified: unverified, Host: cs.summary()}, err } } @@ -82,7 +82,7 @@ func candidateRunner(parent Options, sink event.Sink, approval *candidateApprova // its end. Its transcript lives in a temp dir removed with it; only what sink // forwards reaches the parent session. A run that stopped without proving its // work is not an error: its readiness reason comes back as unverified. -func runUnattended(ctx context.Context, parent Options, sink event.Sink, mode, root, prompt, model string) (answer, unverified string, err error) { +func runUnattended(ctx context.Context, parent Options, sink event.Sink, mode, root, prompt string, selection provider.Selection, maxSteps int) (answer, unverified string, err error) { sessDir, err := os.MkdirTemp("", "reasonix-candidate-") if err != nil { return "", "", err @@ -92,7 +92,9 @@ func runUnattended(ctx context.Context, parent Options, sink event.Sink, mode, r ComputerDriver: parent.ComputerDriver, WorkspaceRoot: root, Home: parent.Home, - Model: model, + Model: selection.Ref, + EffortOverride: selection.Effort, + MaxSteps: maxSteps, AgentPreset: parent.AgentPreset, ProviderResolver: parent.ProviderResolver, Sink: sink, diff --git a/internal/assembly/boot/computer_scope_test.go b/internal/assembly/boot/computer_scope_test.go index fdd0dd0056..2df520d2fc 100644 --- a/internal/assembly/boot/computer_scope_test.go +++ b/internal/assembly/boot/computer_scope_test.go @@ -50,7 +50,7 @@ func TestComputerDriverReachesStudioUnattendedChildSchema(t *testing.T) { approveWorkspace(t, root) driver := computer.NewDriver("/unused", nil) defer driver.Close() - _, _, err := runUnattended(t.Context(), Options{ComputerDriver: driver}, event.Discard, "deny", root, "answer briefly", "") + _, _, err := runUnattended(t.Context(), Options{ComputerDriver: driver}, event.Discard, "deny", root, "answer briefly", provider.Selection{}, 0) if err != nil { t.Fatal(err) } diff --git a/internal/assembly/boot/effect_isolation_runtime_test.go b/internal/assembly/boot/effect_isolation_runtime_test.go new file mode 100644 index 0000000000..62f838b74d --- /dev/null +++ b/internal/assembly/boot/effect_isolation_runtime_test.go @@ -0,0 +1,196 @@ +package boot + +import ( + "context" + "encoding/json" + "fmt" + "os/exec" + "strings" + "sync" + "testing" + + "reasonix/internal/base/testenv" + "reasonix/internal/contract/event" + "reasonix/internal/contract/provider" + "reasonix/internal/session/control" +) + +const isolationRuntimePrompt = "ISOLATED-RUNTIME: inspect a.txt" + +type isolationRuntimeRequest struct { + model, effort string + request provider.Request +} + +type isolationRuntimeRecorder struct { + mu sync.Mutex + requests []isolationRuntimeRequest + args map[string]any + deferred bool + result string +} + +type isolationRuntimeProvider struct { + recorder *isolationRuntimeRecorder + model, effort string +} + +func (*isolationRuntimeProvider) Name() string { return "boot-isolation-runtime" } + +func (p *isolationRuntimeProvider) Stream(_ context.Context, req provider.Request) (<-chan provider.Chunk, error) { + r := p.recorder + r.mu.Lock() + defer r.mu.Unlock() + ch := make(chan provider.Chunk, 2) + call := func(id, name string, args any) { + raw, _ := json.Marshal(args) + ch <- provider.Chunk{Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{ID: id, Name: name, Arguments: string(raw)}} + } + if strings.Contains(firstUser(req), isolationRuntimePrompt) { + r.requests = append(r.requests, isolationRuntimeRequest{model: p.model, effort: p.effort, request: req}) + // A finite scripted ceiling bounds the negative control even without a cap. + if len(r.requests) >= 10 { + ch <- provider.Chunk{Type: provider.ChunkText, Text: "inspection finished"} + } else { + call(fmt.Sprintf("read-%d", len(r.requests)), "read_file", map[string]string{"path": fmt.Sprintf("a-%d.txt", len(r.requests))}) + } + } else if hasToolResult(req) { + r.result = lastToolResult(req) + ch <- provider.Chunk{Type: provider.ChunkText, Text: "parent done"} + } else if r.deferred { + call("delegate", "use_capability", map[string]any{"action": "call", "capability_id": "task:subagent", "arguments": r.args}) + } else { + call("delegate", "task", r.args) + } + ch <- provider.Chunk{Type: provider.ChunkDone} + close(ch) + return ch, nil +} + +func TestEffectIsolatedTaskHonorsRuntimeSelectionAndBudget(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not installed") + } + cases := []struct { + name, configuredModel, inheritedEffort, callModel, callEffort, parentEffort string + wantModel, wantEffort string + unsupported, deriveBudget, noParentCap, shared bool + wantRequests int + }{ + {name: "explicit", configuredModel: "child-model", callModel: "alternate-model", callEffort: "low", wantModel: "alternate", wantEffort: "low"}, + {name: "configured-model", configuredModel: "child-model", callEffort: "low", wantModel: "child", wantEffort: "low"}, + {name: "inherited-effort", configuredModel: "child-model", inheritedEffort: "low", wantModel: "child", wantEffort: "low"}, + {name: "parent-runtime-effort", parentEffort: "low", wantModel: "parent", wantEffort: "low"}, + {name: "unsupported-inherited-effort", callModel: "child-model", inheritedEffort: "max", wantModel: "child", wantEffort: "high"}, + {name: "unsupported-explicit-effort", configuredModel: "child-model", callEffort: "max", unsupported: true}, + {name: "unsupported-configured-pair", configuredModel: "child-model", inheritedEffort: "max", unsupported: true}, + {name: "derived-budget", configuredModel: "child-model", wantModel: "child", wantEffort: "high", deriveBudget: true, wantRequests: 7}, + {name: "automatic-budget", wantModel: "parent", wantEffort: "high", deriveBudget: true, noParentCap: true, wantRequests: 10}, + {name: "shared-parent-alias", callModel: "parent-model", parentEffort: "low", wantModel: "parent", wantEffort: "low", shared: true}, + } + for _, tc := range cases { + for _, deferred := range []bool{false, true} { + t.Run(fmt.Sprintf("%s/deferred=%t", tc.name, deferred), func(t *testing.T) { + home, root := statedBootHome(t), testenv.TempDir(t) + kind := uniqueKind("isolation-runtime") + rec := &isolationRuntimeRecorder{deferred: deferred, args: map[string]any{ + "prompt": isolationRuntimePrompt, "isolation": "worktree", "max_steps": 1, + }} + if tc.deriveBudget { + delete(rec.args, "max_steps") + } + if tc.shared { + delete(rec.args, "isolation") + } + if tc.callModel != "" { + rec.args["model"] = tc.callModel + } + if tc.callEffort != "" { + rec.args["effort"] = tc.callEffort + } + provider.Register(kind, func(cfg provider.Config) (provider.Provider, error) { + effort, _ := cfg.Extra["effort"].(string) + return &isolationRuntimeProvider{recorder: rec, model: cfg.Model, effort: effort}, nil + }) + var config strings.Builder + fmt.Fprintf(&config, ` +default_model = "parent-model" +[agent] +system_prompt = "BASE" +worktree_isolation = true +subagent_model = %q +subagent_effort = %q +[codegraph] +enabled = false +`, tc.configuredModel, tc.inheritedEffort) + for _, model := range []string{"parent", "child", "alternate"} { + fmt.Fprintf(&config, ` +[[providers]] +name = %q +kind = %q +model = %q +supported_efforts = ["low", "high"] +default_effort = "high" +`, model+"-model", kind, model) + } + writeFile(t, root, "reasonix.toml", config.String()) + writeFile(t, root, "a.txt", "read me\n") + for i := 1; i <= 10; i++ { + writeFile(t, root, fmt.Sprintf("a-%d.txt", i), fmt.Sprintf("round %d\n", i)) + } + gitInDir(t, root, "init", "-q") + mirrorToUserConfigAt(t, home, root) + gitInDir(t, root, "add", "-A") + gitInDir(t, root, "commit", "-q", "-m", "init") + opts := Options{Home: home, WorkspaceRoot: root, MaxSteps: 12, Sink: event.Discard} + if tc.noParentCap { + opts.MaxSteps = 0 + } + if tc.parentEffort != "" { + opts.EffortOverride = &tc.parentEffort + } + ctrl, err := Build(t.Context(), opts) + if err != nil { + t.Fatal(err) + } + defer ctrl.Close() + ctrl.SetToolApprovalMode(control.ToolApprovalYolo) + if err := ctrl.Run(t.Context(), "delegate the inspection"); err != nil { + t.Fatal(err) + } + rec.mu.Lock() + defer rec.mu.Unlock() + if tc.unsupported { + if len(rec.requests) != 0 || !strings.Contains(rec.result, "max") { + t.Fatalf("unsupported effort silently executed: requests=%d result=%q", len(rec.requests), rec.result) + } + return + } + wantRequests := tc.wantRequests + if wantRequests == 0 { + wantRequests = 2 + } + if len(rec.requests) != wantRequests { + t.Errorf("child request count = %d, want %d; result=%q", len(rec.requests), wantRequests, rec.result) + } + for _, got := range rec.requests { + if got.model != tc.wantModel || got.effort != tc.wantEffort { + t.Errorf("child runtime = model %q effort %q; want %q/%q", got.model, got.effort, tc.wantModel, tc.wantEffort) + } + } + if len(rec.requests) > 1 { + first, _ := json.Marshal(rec.requests[0].request.Tools) + last, _ := json.Marshal(rec.requests[len(rec.requests)-1].request.Tools) + if string(first) != string(last) || len(rec.requests[0].request.Tools) == 0 { + t.Fatal("child tool definitions changed at finalization") + } + initial := rec.requests[0].request.Messages[0] + final := rec.requests[len(rec.requests)-1].request.Messages[0] + if initial.Role != provider.RoleSystem || final.Role != provider.RoleSystem || initial.Content != final.Content { + t.Fatal("child system prefix changed during the run") + } + } + }) + } + } +} diff --git a/internal/assembly/boot/isolation.go b/internal/assembly/boot/isolation.go index 66b44ad4a0..b288502f44 100644 --- a/internal/assembly/boot/isolation.go +++ b/internal/assembly/boot/isolation.go @@ -56,7 +56,11 @@ func (b *builder) addIsolation() { b.tools.reg.Add(isolation.NewDiscardTool(store)) parent, sink := b.opts, b.sink b.tools.taskTool.SetIsolation(store, b.root, func(ctx context.Context, run isolation.Run) (isolation.Outcome, error) { - answer, unverified, err := runUnattended(ctx, parent, isolatedUsage{sink}, posture.mode(), run.WorkspaceRoot, run.Prompt, run.Model) + _, selection, err := subagentProviderSelection(b.cfg, b.providers.effective, b.model.entry, run.Model, run.Effort) + if err != nil { + return isolation.Outcome{}, err + } + answer, unverified, err := runUnattended(ctx, parent, isolatedUsage{sink}, posture.mode(), run.WorkspaceRoot, run.Prompt, selection, run.MaxSteps) return isolation.Outcome{Answer: answer, Unverified: unverified}, err }) // The registry canonicalizes a schema when a tool is added, and task was diff --git a/internal/assembly/boot/subagent_config.go b/internal/assembly/boot/subagent_config.go index e4ea1ae9b6..519948d956 100644 --- a/internal/assembly/boot/subagent_config.go +++ b/internal/assembly/boot/subagent_config.go @@ -99,29 +99,11 @@ func newSubagentConfig(opts Options, cfg *config.Config, entry *config.ProviderE inherited := resolveInheritedSubagentEffort(cfg, entry) return subagentConfig{ resolveProvider: func(modelRef, effort string) (provider.Provider, *provider.Pricing, int, error) { - me, selectedRef, err := subagentModelEntry(cfg, resolver, entry, modelRef) + me, selection, err := subagentProviderSelection(cfg, resolver, entry, modelRef, effort) if err != nil { return nil, nil, 0, err } - var effortOverride *string - if selectedRef == modelRefFromEntry(entry) { - effortOverride = &me.Effort - } - if strings.TrimSpace(effort) != "" { - normalized, err := config.NormalizeEffort(&me, effort) - if err != nil { - if resolver == nil { - return nil, nil, 0, fmt.Errorf("subagent effort %q on %s: %w", effort, selectedRef, err) - } - normalized = effort - } - me.Effort = normalized - effortOverride = &normalized - if me.Kind == "anthropic" && strings.TrimSpace(me.Effort) != "" && strings.TrimSpace(me.Thinking) == "" { - me.Thinking = "adaptive" - } - } - p, err := resolveProvider(resolver, cfg, proxy, provider.Selection{Ref: selectedRef, Effort: effortOverride}) + p, err := resolveProvider(resolver, cfg, proxy, selection) if err != nil { return nil, nil, 0, err } @@ -148,6 +130,32 @@ func newSubagentConfig(opts Options, cfg *config.Config, entry *config.ProviderE } } +func subagentProviderSelection(cfg *config.Config, resolver provider.Resolver, parent *config.ProviderEntry, modelRef, effort string) (config.ProviderEntry, provider.Selection, error) { + entry, selectedRef, err := subagentModelEntry(cfg, resolver, parent, modelRef) + if err != nil { + return entry, provider.Selection{}, err + } + selection := provider.Selection{Ref: selectedRef} + if selectedRef == modelRefFromEntry(parent) { + selection.Effort = &entry.Effort + } + if strings.TrimSpace(effort) != "" { + normalized, err := config.NormalizeEffort(&entry, effort) + if err != nil { + if resolver == nil { + return entry, selection, fmt.Errorf("subagent effort %q on %s: %w", effort, selectedRef, err) + } + normalized = effort + } + entry.Effort = normalized + selection.Effort = &normalized + if entry.Kind == "anthropic" && strings.TrimSpace(entry.Effort) != "" && strings.TrimSpace(entry.Thinking) == "" { + entry.Thinking = "adaptive" + } + } + return entry, selection, nil +} + func subagentModelEntry(cfg *config.Config, resolver provider.Resolver, parent *config.ProviderEntry, ref string) (config.ProviderEntry, string, error) { ref = childModelRef(cfg, parent, ref) if parent != nil && (ref == "" || ref == modelRefFromEntry(parent)) { @@ -155,6 +163,9 @@ func subagentModelEntry(cfg *config.Config, resolver provider.Resolver, parent * } if cfg != nil { if resolved, ok := cfg.ResolveModel(ref); ok { + if parent != nil && modelRefFromEntry(resolved) == modelRefFromEntry(parent) { + return *parent, modelRefFromEntry(parent), nil + } return *resolved, modelRefFromEntry(resolved), nil } } diff --git a/internal/assembly/boot/subagent_selection_alias_test.go b/internal/assembly/boot/subagent_selection_alias_test.go new file mode 100644 index 0000000000..a944f28073 --- /dev/null +++ b/internal/assembly/boot/subagent_selection_alias_test.go @@ -0,0 +1,34 @@ +package boot + +import ( + "testing" + + "reasonix/internal/contract/config" +) + +func TestSubagentSelectionPreservesRuntimeForParentAliases(t *testing.T) { + cfg := config.Default() + cfg.Providers = []config.ProviderEntry{{Name: "parent", Kind: "openai", Models: []string{"p", "q"}, Default: "p", + SupportedEfforts: []string{"low", "high"}, DefaultEffort: "high"}} + parent, ok := cfg.ResolveModel("parent/p") + if !ok { + t.Fatal("parent model missing") + } + parent.Effort = "low" + for _, ref := range []string{"", "parent", "parent/p", "p"} { + t.Run(ref, func(t *testing.T) { + _, selection, err := subagentProviderSelection(cfg, nil, parent, ref, "") + if err != nil || selection.Ref != "parent/p" || selection.Effort == nil || *selection.Effort != "low" { + t.Fatalf("parent runtime selection for %q = %+v, %v", ref, selection, err) + } + }) + } + _, other, err := subagentProviderSelection(cfg, nil, parent, "parent/q", "") + if err != nil || other.Ref != "parent/q" || other.Effort != nil { + t.Fatalf("another model inherited the parent's override: %+v, %v", other, err) + } + _, explicit, err := subagentProviderSelection(cfg, nil, parent, "parent", "high") + if err != nil || explicit.Effort == nil || *explicit.Effort != "high" { + t.Fatalf("explicit effort did not narrow the inherited selection: %+v, %v", explicit, err) + } +} diff --git a/internal/runtime/delegation/isolated_task.go b/internal/runtime/delegation/isolated_task.go index ca4b148c43..8de4105c25 100644 --- a/internal/runtime/delegation/isolated_task.go +++ b/internal/runtime/delegation/isolated_task.go @@ -35,8 +35,9 @@ func (t *TaskTool) SetIsolation(store *isolation.Store, root string, runner isol // isolatedCall is the part of a task call an isolated run reads, and the // arguments it has to refuse rather than silently drop. type isolatedCall struct { - mode, prompt, model string - unsupported []string + mode, prompt, model, effort string + maxSteps int + unsupported []string } func (t *TaskTool) executeIsolated(ctx context.Context, call isolatedCall) (string, error) { @@ -50,7 +51,17 @@ func (t *TaskTool) executeIsolated(ctx context.Context, call isolatedCall) (stri return "", tool.Refusal{Code: codeIsolationUnsupported, Message: "an isolated task runs as its own agent and cannot take " + strings.Join(call.unsupported, ", ") + "; drop them or run without isolation"} } - return t.isolated.store.Execute(ctx, t.isolated.runner, t.isolated.root, call.prompt, strings.TrimSpace(call.model)) + model, effort := t.resolveModelEffort("", "", call.model, call.effort, "", "") + if model == "" { + model = t.baseModel + } + model, effort = t.childRuntimeRef(ctx, model, effort) + steps := t.childMaxStepsForContext(ctx, call.maxSteps) + runner := func(ctx context.Context, run isolation.Run) (isolation.Outcome, error) { + run.Effort, run.MaxSteps = effort, steps + return t.isolated.runner(ctx, run) + } + return t.isolated.store.Execute(ctx, runner, t.isolated.root, call.prompt, model) } // isolatedUnsupported names the task arguments that were set and that an diff --git a/internal/runtime/delegation/task.go b/internal/runtime/delegation/task.go index 27e0437c6c..c7111759fb 100644 --- a/internal/runtime/delegation/task.go +++ b/internal/runtime/delegation/task.go @@ -418,7 +418,7 @@ func (t *TaskTool) executeTask(ctx context.Context, args json.RawMessage) (strin return "", fmt.Errorf("prompt is required") } if p.Isolation != "" { - return t.executeIsolated(ctx, isolatedCall{mode: p.Isolation, prompt: p.Prompt, model: p.Model, + return t.executeIsolated(ctx, isolatedCall{mode: p.Isolation, prompt: p.Prompt, model: p.Model, effort: p.Effort, maxSteps: p.MaxSteps, unsupported: isolatedUnsupported(p.Profile, p.WritePaths, p.Tools, p.ContinueFrom, p.ForkFrom, p.RunInBackground)}) } diff --git a/internal/runtime/isolation/run.go b/internal/runtime/isolation/run.go index b276def521..1e6ee1fdae 100644 --- a/internal/runtime/isolation/run.go +++ b/internal/runtime/isolation/run.go @@ -12,6 +12,8 @@ type Run struct { WorkspaceRoot string Prompt string Model string + Effort string + MaxSteps int } // Outcome is what the run ended with. Unverified carries the readiness reason diff --git a/release-notes/studio/unreleased/14025-isolation-runtime-options.md b/release-notes/studio/unreleased/14025-isolation-runtime-options.md new file mode 100644 index 0000000000..68258f897f --- /dev/null +++ b/release-notes/studio/unreleased/14025-isolation-runtime-options.md @@ -0,0 +1,7 @@ +## 修复 + +- worktree 隔离任务遵守显式或继承的模型、effort 与步数上限;同模型别名保留 + 父会话实际 effort。原本不支持的工具/路径限制、后台和续跑仍明确拒绝。Refs #14025 +- Worktree tasks honor model, effort and step caps. Parent aliases preserve + session effort; unsupported restrictions and lifecycle arguments remain + refused. Refs #14025