mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-08 09:49:34 -10:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1141d258ec |
No files matched your search
@@ -20,15 +20,10 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
# `zero-release package` builds + smoke-tests natively (host==target), so each
|
||||
# target platform needs its own native runner. These five cover the platforms
|
||||
# install.sh / install.ps1 / the npm wrapper resolve.
|
||||
os:
|
||||
- ubuntu-latest # linux-x64
|
||||
- ubuntu-24.04-arm # linux-arm64
|
||||
- macos-latest # macos-arm64 (Apple Silicon)
|
||||
- macos-15-intel # macos-x64 (Intel)
|
||||
- windows-latest # windows-x64
|
||||
- ubuntu-latest
|
||||
- macos-latest
|
||||
- windows-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
|
||||
@@ -6,7 +6,7 @@ alwaysApply: false
|
||||
|
||||
# Extending Zero
|
||||
|
||||
Zero is an open-source terminal coding agent. Out of the box it does the obvious things — read, edit, run, search — but the design point of the project is that **every surface is configurable**. This document is the user-facing guide for that configuration.
|
||||
Zero is an open-source terminal coding agent in the same family as Claude Code, Codex, and Droid. Out of the box it does the obvious things — read, edit, run, search — but the design point of the project is that **every surface is configurable**. This document is the user-facing guide for that configuration.
|
||||
|
||||
If you only want to *use* Zero, the [README](README.md) is enough. This page is for the other three jobs:
|
||||
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Gitlawb
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -17,7 +17,6 @@
|
||||

|
||||

|
||||

|
||||
[](LICENSE)
|
||||
|
||||
</div>
|
||||
|
||||
@@ -355,7 +354,7 @@ Contributions are welcome — see [CONTRIBUTING.md](CONTRIBUTING.md). Run `go te
|
||||
|
||||
## License
|
||||
|
||||
Zero is released under the [MIT License](LICENSE) — © 2026 Gitlawb.
|
||||
License is being finalized; a `LICENSE` file will be added before the public release.
|
||||
|
||||
---
|
||||
|
||||
|
||||
-49
@@ -9,49 +9,8 @@ function zeroBinaryName(platform = process.platform) {
|
||||
return platform === 'win32' ? 'zero.exe' : 'zero';
|
||||
}
|
||||
|
||||
function helperShimNames(name, platform = process.platform) {
|
||||
if (platform === 'win32') {
|
||||
return [`${name}.cmd`, `${name}.exe`, name];
|
||||
}
|
||||
return [name];
|
||||
}
|
||||
|
||||
function commandForShim(path, platform = process.platform) {
|
||||
if (platform === 'win32' && path.toLowerCase().endsWith('.cmd')) {
|
||||
return {
|
||||
command: process.env.ComSpec || 'cmd.exe',
|
||||
prefixArgs: ['/d', '/s', '/c', `"${path.replace(/"/g, '""')}"`],
|
||||
};
|
||||
}
|
||||
return { command: path, prefixArgs: [] };
|
||||
}
|
||||
|
||||
function resolveHelper(packageRoot, name) {
|
||||
const binDir = join(packageRoot, 'node_modules', '.bin');
|
||||
for (const shimName of helperShimNames(name)) {
|
||||
const candidate = join(binDir, shimName);
|
||||
if (!existsSync(candidate)) continue;
|
||||
return {
|
||||
...commandForShim(candidate),
|
||||
pathPrepend: [binDir],
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function localControlHelperManifest(packageRoot) {
|
||||
const helpers = {};
|
||||
for (const name of ['agent-browser', 'tuistory']) {
|
||||
const helper = resolveHelper(packageRoot, name);
|
||||
if (helper) helpers[name] = helper;
|
||||
}
|
||||
if (Object.keys(helpers).length === 0) return '';
|
||||
return JSON.stringify({ version: 1, helpers });
|
||||
}
|
||||
|
||||
const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
|
||||
const nativePath = join(packageRoot, zeroBinaryName());
|
||||
const localControlHelpers = localControlHelperManifest(packageRoot);
|
||||
|
||||
if (!existsSync(nativePath)) {
|
||||
console.error(
|
||||
@@ -60,16 +19,8 @@ if (!existsSync(nativePath)) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const env = { ...process.env };
|
||||
if (localControlHelpers) {
|
||||
env.ZERO_LOCAL_CONTROL_HELPERS = localControlHelpers;
|
||||
} else {
|
||||
delete env.ZERO_LOCAL_CONTROL_HELPERS;
|
||||
}
|
||||
|
||||
const child = spawnSync(nativePath, process.argv.slice(2), {
|
||||
stdio: 'inherit',
|
||||
env,
|
||||
});
|
||||
|
||||
if (child.error) {
|
||||
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
# Zero — Product Requirements Document (v1.0)
|
||||
|
||||
**Status:** DRAFT v1.0
|
||||
**Target:** Compete with Claude Code + Cursor + other multi-provider terminal agents
|
||||
**Target:** Compete with Claude Code + Cursor + OpenCode
|
||||
**Timeline:** 6-12 months
|
||||
**Stack:** Go native core + npm distribution wrapper
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
It combines the best of:
|
||||
- **Claude Code** — terminal-native agentic coding
|
||||
- **Cursor** — IDE integration + smart editing
|
||||
- **Multi-provider terminal agents** — provider-agnostic + headless
|
||||
- **OpenCode** — multi-provider + headless
|
||||
|
||||
**One Go-native agent core. Three surfaces. Zero lock-in.**
|
||||
|
||||
|
||||
@@ -170,7 +170,7 @@ preset (no API key — the proxy authenticates):
|
||||
"name": "chatgpt",
|
||||
"catalogID": "chatgpt-proxy", // OpenAI-compatible, local, no key
|
||||
"baseURL": "http://localhost:10531/v1", // override for your proxy's port
|
||||
"model": "gpt-5.5" // whatever model your proxy serves
|
||||
"model": "gpt-5" // whatever model your proxy serves
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ require (
|
||||
charm.land/lipgloss/v2 v2.0.4
|
||||
github.com/alecthomas/chroma/v2 v2.27.0
|
||||
github.com/atotto/clipboard v0.1.4
|
||||
github.com/aymanbagabas/go-udiff v0.4.1
|
||||
github.com/charmbracelet/colorprofile v0.4.3
|
||||
github.com/charmbracelet/x/ansi v0.11.7
|
||||
github.com/ledongthuc/pdf v0.0.0-20250511090121-5959a4027728
|
||||
|
||||
@@ -1,228 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
func enabledSelfCorrector(t *testing.T) *SelfCorrector {
|
||||
t.Helper()
|
||||
// checker/verifier nil → no LSP / project-test pass runs; we only need a
|
||||
// non-nil SelfCorrector to enable the task-grounded acceptance gate.
|
||||
return NewSelfCorrector(t.TempDir(), nil, nil, SelfCorrectConfig{Enabled: true})
|
||||
}
|
||||
|
||||
// BUG #2(a): a final message that admits the model guessed / could not meet the
|
||||
// objective must be reported as INCOMPLETE, never success — and immediately
|
||||
// (an admitted guess is not worth re-prompting).
|
||||
func TestAcceptanceSelfReportAdmissionIsIncomplete(t *testing.T) {
|
||||
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
|
||||
textTurn("I couldn't analyze the board image, so I wrote the common opening move e2e4 as my best guess."),
|
||||
}}
|
||||
|
||||
result, err := Run(context.Background(), "write the best chess move", provider, Options{
|
||||
Registry: tools.NewRegistry(),
|
||||
MaxTurns: 10,
|
||||
RequireCompletionSignal: true,
|
||||
SelfCorrect: enabledSelfCorrector(t),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !result.Incomplete {
|
||||
t.Fatalf("admitted guess must be Incomplete, got success; final=%q", result.FinalAnswer)
|
||||
}
|
||||
if !strings.Contains(result.IncompleteReason, "objective was not met") {
|
||||
t.Fatalf("IncompleteReason = %q, want it to cite the admission", result.IncompleteReason)
|
||||
}
|
||||
if len(provider.requests) != 1 {
|
||||
t.Fatalf("an admission should downgrade immediately (1 turn), got %d", len(provider.requests))
|
||||
}
|
||||
}
|
||||
|
||||
// BUG #2(a): the inability detector must generalize over the verb the model uses
|
||||
// (so re-phrasing can't defeat it) while NOT misreading success-y negations
|
||||
// ("could not find any issues") as admissions.
|
||||
func TestSelfReportedIncompletionMatching(t *testing.T) {
|
||||
cases := []struct {
|
||||
text string
|
||||
admit bool
|
||||
}{
|
||||
// real chess re-run wording that slipped a fixed phrase list:
|
||||
{"…which I cannot do without proper image analysis capabilities.", true},
|
||||
{"I cannot visually analyze the chess board image.", true},
|
||||
{"I could not recover the original values, so this is my best guess.", true},
|
||||
{"I was unable to complete the optimization.", true},
|
||||
{"The result may not be correct.", true},
|
||||
// multi-occurrence: an early guarded use ("could not find any") must NOT mask a
|
||||
// later genuine admission with the same stem (review #5):
|
||||
{"I could not find any examples in the codebase, so I could not implement the feature.", true},
|
||||
// genuine successes that must NOT be flagged:
|
||||
{"I implemented the function and all tests pass.", false},
|
||||
{"I verified the fix; I could not find any remaining issues.", false},
|
||||
{"I cannot reproduce the bug, so the fix holds.", false},
|
||||
{"Done — the output matches the required format and values.", false},
|
||||
// behavior descriptions that must NOT be flagged — these phrases describe
|
||||
// implemented behavior, not an admission (review #1, removed from the list):
|
||||
{"The parser will fall back to UTF-8 when no encoding is set.", false},
|
||||
{"I replaced the placeholder value with the computed key.", false},
|
||||
{"The retry uses exponential backoff as a fallback.", false},
|
||||
{"Without proper error handling the function would crash, so I added it.", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := selfReportedIncompletion(c.text) != ""
|
||||
if got != c.admit {
|
||||
t.Errorf("selfReportedIncompletion(%q) admitted=%v, want %v", c.text, got, c.admit)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// BUG #2(a): a "cannot analyze / cannot determine" admission (present tense) must
|
||||
// be caught even when the model also stamps a bogus "PASS" based on a shape check.
|
||||
// Mirrors the chess-best-move final ("I cannot analyze the position… PASS: I wrote
|
||||
// the move and verified by reading it back").
|
||||
func TestAcceptanceCannotPhrasingIsIncomplete(t *testing.T) {
|
||||
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
|
||||
textTurn("I cannot analyze the specific position from the image, so I cannot determine the best move. PASS: I wrote e2e4 to the file and verified it by reading it back."),
|
||||
}}
|
||||
|
||||
result, err := Run(context.Background(), "write the best move", provider, Options{
|
||||
Registry: tools.NewRegistry(),
|
||||
MaxTurns: 10,
|
||||
RequireCompletionSignal: true,
|
||||
SelfCorrect: enabledSelfCorrector(t),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !result.Incomplete {
|
||||
t.Fatalf("a 'cannot analyze/determine' admission must be Incomplete despite the bogus PASS; final=%q", result.FinalAnswer)
|
||||
}
|
||||
}
|
||||
|
||||
// A pending/in_progress update_plan item is an AMBIGUOUS signal (the model may have
|
||||
// finished without re-marking the step), so it must NOT by itself force INCOMPLETE.
|
||||
// The gate nudges, but if the model keeps claiming completion with no continuation
|
||||
// cue and no admission, the run finalizes as SUCCESS — trusting the completion claim
|
||||
// over stale plan bookkeeping. (Only a continuation cue or self-report admission
|
||||
// forces INCOMPLETE; see TestCompletionGate* and TestAcceptance*Admission*.)
|
||||
func TestPendingPlanAloneDoesNotForceIncomplete(t *testing.T) {
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewUpdatePlanTool())
|
||||
done := "All set." // confident; no cue, no admission — only the plan is stale
|
||||
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
|
||||
planTurn("completed", "in_progress"),
|
||||
textTurn(done), textTurn(done), textTurn(done), textTurn(done), textTurn(done),
|
||||
}}
|
||||
|
||||
result, err := Run(context.Background(), "do the multi-step task", provider, Options{
|
||||
Registry: registry,
|
||||
MaxTurns: 10,
|
||||
RequireCompletionSignal: true,
|
||||
SelfCorrect: enabledSelfCorrector(t),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Incomplete {
|
||||
t.Fatalf("pending-plan alone must NOT force Incomplete (stale bookkeeping != unfinished); reason=%q", result.IncompleteReason)
|
||||
}
|
||||
if result.FinalAnswer != done {
|
||||
t.Fatalf("final answer = %q, want the completion claim %q", result.FinalAnswer, done)
|
||||
}
|
||||
if !someRequestContains(provider.requests, continueNudgeMarker) {
|
||||
t.Fatalf("expected at least one continue-nudge before accepting completion")
|
||||
}
|
||||
}
|
||||
|
||||
// BUG #2(b): when self-correct is on, the run must pass a one-time task-grounded
|
||||
// acceptance check before success. A model that, when challenged, confirms its
|
||||
// work meets the criterion (no admission) then finalizes as success — no regression.
|
||||
func TestAcceptanceGroundedCompletionSucceeds(t *testing.T) {
|
||||
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
|
||||
textTurn("I implemented the function."),
|
||||
textTurn("PASS. I ran the verification and it passes; the output meets the task requirement."),
|
||||
}}
|
||||
|
||||
result, err := Run(context.Background(), "implement the function", provider, Options{
|
||||
Registry: tools.NewRegistry(),
|
||||
MaxTurns: 10,
|
||||
RequireCompletionSignal: true,
|
||||
SelfCorrect: enabledSelfCorrector(t),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Incomplete {
|
||||
t.Fatalf("a verified completion must succeed, got Incomplete (%q)", result.IncompleteReason)
|
||||
}
|
||||
if result.FinalAnswer != "PASS. I ran the verification and it passes; the output meets the task requirement." {
|
||||
t.Fatalf("final answer = %q", result.FinalAnswer)
|
||||
}
|
||||
// Exactly one acceptance pass: the first "done" turn was challenged, the second confirmed.
|
||||
if len(provider.requests) != 2 {
|
||||
t.Fatalf("expected 2 turns (1 acceptance challenge + 1 confirmation), got %d", len(provider.requests))
|
||||
}
|
||||
if !someRequestContains(provider.requests, acceptanceNudgeMarker) {
|
||||
t.Fatalf("expected the task-grounded acceptance check to be demanded")
|
||||
}
|
||||
}
|
||||
|
||||
// With the gate OFF (interactive/TUI default), even an admission is accepted as the
|
||||
// final answer exactly as before — proving no behavior change for non-headless callers.
|
||||
func TestAcceptanceGateOffLeavesAdmissionAsSuccess(t *testing.T) {
|
||||
admission := "I couldn't determine the value, so this is a guess."
|
||||
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
|
||||
textTurn(admission),
|
||||
}}
|
||||
|
||||
result, err := Run(context.Background(), "do a thing", provider, Options{
|
||||
Registry: tools.NewRegistry(),
|
||||
MaxTurns: 10,
|
||||
// RequireCompletionSignal false, SelfCorrect nil — the legacy path.
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Incomplete {
|
||||
t.Fatalf("legacy path must never set Incomplete; final=%q", result.FinalAnswer)
|
||||
}
|
||||
if result.FinalAnswer != admission {
|
||||
t.Fatalf("final answer = %q, want the text verbatim (legacy behavior)", result.FinalAnswer)
|
||||
}
|
||||
if len(provider.requests) != 1 {
|
||||
t.Fatalf("legacy path must not re-prompt; got %d turns", len(provider.requests))
|
||||
}
|
||||
}
|
||||
|
||||
// The acceptance check is scoped to runs with self-correct on: a headless run
|
||||
// WITHOUT a SelfCorrector accepts a confident completion immediately (the (b) gate
|
||||
// is inert), so enabling RequireCompletionSignal alone adds no acceptance turn.
|
||||
func TestAcceptanceGateRequiresSelfCorrect(t *testing.T) {
|
||||
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
|
||||
textTurn("Done. Implemented and it works."),
|
||||
}}
|
||||
|
||||
result, err := Run(context.Background(), "implement it", provider, Options{
|
||||
Registry: tools.NewRegistry(),
|
||||
MaxTurns: 10,
|
||||
RequireCompletionSignal: true,
|
||||
// SelfCorrect deliberately nil → acceptance gate (b) must not fire.
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Incomplete {
|
||||
t.Fatalf("confident completion without self-correct must succeed; reason=%q", result.IncompleteReason)
|
||||
}
|
||||
if len(provider.requests) != 1 {
|
||||
t.Fatalf("acceptance gate must not fire without self-correct; got %d turns", len(provider.requests))
|
||||
}
|
||||
if someRequestContains(provider.requests, acceptanceNudgeMarker) {
|
||||
t.Fatalf("acceptance nudge must not be injected when self-correct is off")
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,9 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"mvdan.cc/sh/v3/syntax"
|
||||
)
|
||||
|
||||
@@ -17,106 +15,43 @@ func proposedCommandPrefix(toolName string, args map[string]any) []string {
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
segments, ok := safeShellCommandSegments(command)
|
||||
tokens, ok := safeShellCommandTokens(command)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
if requested, ok := requestedPrefixRule(args); ok {
|
||||
if safeRequestedPrefixForSegments(requested, segments) {
|
||||
if safeRequestedPrefix(requested, tokens) {
|
||||
return requested
|
||||
}
|
||||
return nil
|
||||
}
|
||||
for _, tokens := range segments {
|
||||
if !knownSafeCommandSegment(tokens) && sandbox.ValidCommandPrefix(tokens) {
|
||||
return append([]string(nil), tokens...)
|
||||
}
|
||||
}
|
||||
if len(segments) == 0 || !sandbox.ValidCommandPrefix(segments[0]) {
|
||||
if !sandbox.ValidCommandPrefix(tokens) {
|
||||
return nil
|
||||
}
|
||||
return append([]string(nil), segments[0]...)
|
||||
return append([]string(nil), tokens...)
|
||||
}
|
||||
|
||||
func matchCommandPrefix(toolName string, args map[string]any, options Options) (sandbox.CommandPrefixGrant, bool, bool) {
|
||||
if !isShellCommandTool(toolName) || options.Sandbox == nil {
|
||||
return sandbox.CommandPrefixGrant{}, false, false
|
||||
}
|
||||
if shellCommandAdditionalPermissionsRequested(args) {
|
||||
return sandbox.CommandPrefixGrant{}, false, false
|
||||
}
|
||||
command, ok := firstStringArg(args, "command", "cmd", "script", "shell")
|
||||
if !ok {
|
||||
return sandbox.CommandPrefixGrant{}, false, false
|
||||
}
|
||||
segments, ok := safeShellCommandSegments(command)
|
||||
tokens, ok := safeShellCommandTokens(command)
|
||||
if !ok {
|
||||
return sandbox.CommandPrefixGrant{}, false, false
|
||||
}
|
||||
var matched sandbox.CommandPrefixGrant
|
||||
matchedAny := false
|
||||
matchedSession := false
|
||||
for _, tokens := range segments {
|
||||
if grant, ok := options.Sandbox.LookupCommandPrefix(toolName, tokens); ok {
|
||||
if !matchedAny {
|
||||
matched = grant
|
||||
}
|
||||
matchedAny = true
|
||||
continue
|
||||
}
|
||||
if grant, ok := options.Sandbox.LookupCommandPrefixForSession(toolName, tokens); ok {
|
||||
if !matchedAny {
|
||||
matched = grant
|
||||
}
|
||||
matchedAny = true
|
||||
matchedSession = true
|
||||
continue
|
||||
}
|
||||
if knownSafeCommandSegment(tokens) {
|
||||
continue
|
||||
}
|
||||
return sandbox.CommandPrefixGrant{}, false, false
|
||||
if grant, ok := options.Sandbox.LookupCommandPrefix(toolName, tokens); ok {
|
||||
return grant, true, false
|
||||
}
|
||||
if matchedAny {
|
||||
return matched, true, matchedSession
|
||||
if grant, ok := options.Sandbox.LookupCommandPrefixForSession(toolName, tokens); ok {
|
||||
return grant, true, true
|
||||
}
|
||||
return sandbox.CommandPrefixGrant{}, false, false
|
||||
}
|
||||
|
||||
func shellExecutionArgsForApproval(toolName string, args map[string]any, action PermissionDecisionAction, options Options) map[string]any {
|
||||
if !isShellCommandTool(toolName) || !shellPrefixApprovalBypassesSandbox(action) {
|
||||
return args
|
||||
}
|
||||
if options.Sandbox == nil || !options.Sandbox.UnsandboxedExecutionAllowed() {
|
||||
return args
|
||||
}
|
||||
if shellCommandAdditionalPermissionsRequested(args) || shellCommandRequiresEscalated(args) {
|
||||
return args
|
||||
}
|
||||
planned := cloneArgs(args)
|
||||
if planned == nil {
|
||||
planned = map[string]any{}
|
||||
}
|
||||
planned["sandbox_permissions"] = string(tools.SandboxPermissionsRequireEscalated)
|
||||
return planned
|
||||
}
|
||||
|
||||
func shellPrefixApprovalBypassesSandbox(action PermissionDecisionAction) bool {
|
||||
return action == PermissionDecisionAllowPrefix || action == PermissionDecisionAlwaysAllowPrefix
|
||||
}
|
||||
|
||||
func shellCommandRequiresEscalated(args map[string]any) bool {
|
||||
raw, ok := args["sandbox_permissions"]
|
||||
if !ok || raw == nil {
|
||||
return false
|
||||
}
|
||||
value, ok := raw.(string)
|
||||
if !ok {
|
||||
value = fmt.Sprint(raw)
|
||||
}
|
||||
return strings.TrimSpace(value) == string(tools.SandboxPermissionsRequireEscalated)
|
||||
}
|
||||
|
||||
func isShellCommandTool(toolName string) bool {
|
||||
return toolName == "bash" || toolName == "exec_command"
|
||||
}
|
||||
@@ -169,92 +104,32 @@ func cleanPrefixRule(prefix []string) []string {
|
||||
}
|
||||
|
||||
func safeRequestedPrefix(prefix []string, command []string) bool {
|
||||
return safeRequestedPrefixForSegments(prefix, [][]string{command})
|
||||
}
|
||||
|
||||
func safeRequestedPrefixForSegments(prefix []string, segments [][]string) bool {
|
||||
if len(prefix) == 0 || !sandbox.ValidCommandPrefix(prefix) {
|
||||
if len(prefix) == 0 || !sandbox.ValidCommandPrefix(prefix) || len(prefix) > len(command) {
|
||||
return false
|
||||
}
|
||||
matched := false
|
||||
for _, command := range segments {
|
||||
if len(prefix) > len(command) {
|
||||
if knownSafeCommandSegment(command) {
|
||||
continue
|
||||
}
|
||||
for index := range prefix {
|
||||
if prefix[index] != command[index] {
|
||||
return false
|
||||
}
|
||||
if hasStringPrefix(command, prefix) {
|
||||
matched = true
|
||||
continue
|
||||
}
|
||||
if knownSafeCommandSegment(command) {
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
return matched
|
||||
return true
|
||||
}
|
||||
|
||||
func safeShellCommandTokens(command string) ([]string, bool) {
|
||||
segments, ok := safeShellCommandSegments(command)
|
||||
if !ok || len(segments) != 1 {
|
||||
return nil, false
|
||||
}
|
||||
return segments[0], true
|
||||
}
|
||||
|
||||
func safeShellCommandSegments(command string) ([][]string, bool) {
|
||||
command = strings.TrimSpace(command)
|
||||
if command == "" {
|
||||
return nil, false
|
||||
}
|
||||
file, err := syntax.NewParser().Parse(strings.NewReader(command), "")
|
||||
if err != nil || len(file.Stmts) == 0 {
|
||||
if err != nil || len(file.Stmts) != 1 {
|
||||
return nil, false
|
||||
}
|
||||
segments := make([][]string, 0, len(file.Stmts))
|
||||
for _, stmt := range file.Stmts {
|
||||
if !collectSafeShellStatement(stmt, &segments) {
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
if len(segments) == 0 {
|
||||
stmt := file.Stmts[0]
|
||||
if stmt.Negated || stmt.Background || stmt.Coprocess || stmt.Disown || stmt.Semicolon.IsValid() || len(stmt.Redirs) > 0 {
|
||||
return nil, false
|
||||
}
|
||||
return segments, true
|
||||
}
|
||||
|
||||
func collectSafeShellStatement(stmt *syntax.Stmt, segments *[][]string) bool {
|
||||
if stmt == nil || stmt.Negated || stmt.Background || stmt.Coprocess || stmt.Disown || len(stmt.Redirs) > 0 {
|
||||
return false
|
||||
}
|
||||
return collectSafeShellCommand(stmt.Cmd, segments)
|
||||
}
|
||||
|
||||
func collectSafeShellCommand(cmd syntax.Command, segments *[][]string) bool {
|
||||
switch node := cmd.(type) {
|
||||
case *syntax.CallExpr:
|
||||
tokens, ok := literalCallTokens(node)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
*segments = append(*segments, tokens)
|
||||
return true
|
||||
case *syntax.BinaryCmd:
|
||||
switch node.Op {
|
||||
case syntax.AndStmt, syntax.OrStmt, syntax.Pipe:
|
||||
default:
|
||||
return false
|
||||
}
|
||||
return collectSafeShellStatement(node.X, segments) && collectSafeShellStatement(node.Y, segments)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func literalCallTokens(call *syntax.CallExpr) ([]string, bool) {
|
||||
if call == nil || len(call.Assigns) > 0 || len(call.Args) == 0 {
|
||||
call, ok := stmt.Cmd.(*syntax.CallExpr)
|
||||
if !ok || len(call.Assigns) > 0 || len(call.Args) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
tokens := make([]string, 0, len(call.Args))
|
||||
@@ -263,228 +138,17 @@ func literalCallTokens(call *syntax.CallExpr) ([]string, bool) {
|
||||
return nil, false
|
||||
}
|
||||
lit, ok := word.Parts[0].(*syntax.Lit)
|
||||
if !ok || strings.ContainsAny(lit.Value, "*?[]{}") {
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
tokens = append(tokens, lit.Value)
|
||||
}
|
||||
if !sandbox.ValidCommandPrefix(tokens) {
|
||||
return nil, false
|
||||
}
|
||||
return tokens, true
|
||||
}
|
||||
|
||||
func knownSafeCommandSegment(command []string) bool {
|
||||
if len(command) == 0 {
|
||||
return false
|
||||
}
|
||||
switch commandName(command[0]) {
|
||||
case "cat", "cd", "cut", "echo", "expr", "false", "grep", "head", "id",
|
||||
"ls", "nl", "paste", "pwd", "rev", "seq", "stat", "tail", "tr",
|
||||
"true", "uname", "uniq", "wc", "which", "whoami":
|
||||
return true
|
||||
case "base64":
|
||||
return safeBase64Command(command[1:])
|
||||
case "find":
|
||||
return safeFindCommand(command[1:])
|
||||
case "rg":
|
||||
return safeRipgrepCommand(command[1:])
|
||||
case "sed":
|
||||
return safeSedCommand(command[1:])
|
||||
case "git":
|
||||
return safeGitCommand(command)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func safeBase64Command(args []string) bool {
|
||||
for _, arg := range args {
|
||||
if arg == "-o" || arg == "--output" || strings.HasPrefix(arg, "--output=") || (strings.HasPrefix(arg, "-o") && arg != "-o") {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func safeFindCommand(args []string) bool {
|
||||
unsafe := map[string]bool{
|
||||
"-exec": true, "-execdir": true, "-ok": true, "-okdir": true,
|
||||
"-delete": true,
|
||||
"-fls": true, "-fprint": true, "-fprint0": true, "-fprintf": true,
|
||||
}
|
||||
for _, arg := range args {
|
||||
if unsafe[arg] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func safeRipgrepCommand(args []string) bool {
|
||||
for _, arg := range args {
|
||||
if arg == "--search-zip" || arg == "-z" || arg == "--pre" || arg == "--hostname-bin" ||
|
||||
strings.HasPrefix(arg, "--pre=") || strings.HasPrefix(arg, "--hostname-bin=") {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func safeSedCommand(args []string) bool {
|
||||
if len(args) < 2 || len(args) > 3 || args[0] != "-n" {
|
||||
return false
|
||||
}
|
||||
return validSedPrintArg(args[1])
|
||||
}
|
||||
|
||||
func validSedPrintArg(arg string) bool {
|
||||
if !strings.HasSuffix(arg, "p") {
|
||||
return false
|
||||
}
|
||||
body := strings.TrimSuffix(arg, "p")
|
||||
if body == "" {
|
||||
return false
|
||||
}
|
||||
for _, part := range strings.Split(body, ",") {
|
||||
if part == "" {
|
||||
return false
|
||||
}
|
||||
for _, r := range part {
|
||||
if r < '0' || r > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func safeGitCommand(command []string) bool {
|
||||
subIndex, subcommand, ok := gitSubcommand(command)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if gitHasUnsafeGlobalOption(command[1:subIndex]) {
|
||||
return false
|
||||
}
|
||||
args := command[subIndex+1:]
|
||||
switch subcommand {
|
||||
case "status", "log", "diff", "show":
|
||||
return gitArgsReadOnly(args)
|
||||
case "branch":
|
||||
return gitArgsReadOnly(args) && gitBranchReadOnly(args)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func gitSubcommand(command []string) (int, string, bool) {
|
||||
for index := 1; index < len(command); index++ {
|
||||
arg := command[index]
|
||||
if gitOptionConsumesValue(arg) {
|
||||
index++
|
||||
continue
|
||||
}
|
||||
if gitOptionHasInlineValue(arg) || arg == "--" || strings.HasPrefix(arg, "-") {
|
||||
continue
|
||||
}
|
||||
switch arg {
|
||||
case "status", "log", "diff", "show", "branch":
|
||||
return index, arg, true
|
||||
default:
|
||||
return 0, "", false
|
||||
}
|
||||
}
|
||||
return 0, "", false
|
||||
}
|
||||
|
||||
func gitOptionConsumesValue(arg string) bool {
|
||||
switch arg {
|
||||
case "-C", "-c", "--config-env", "--exec-path", "--git-dir", "--namespace", "--super-prefix", "--work-tree":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func gitOptionHasInlineValue(arg string) bool {
|
||||
return strings.HasPrefix(arg, "--config-env=") ||
|
||||
strings.HasPrefix(arg, "--exec-path=") ||
|
||||
strings.HasPrefix(arg, "--git-dir=") ||
|
||||
strings.HasPrefix(arg, "--namespace=") ||
|
||||
strings.HasPrefix(arg, "--super-prefix=") ||
|
||||
strings.HasPrefix(arg, "--work-tree=") ||
|
||||
((strings.HasPrefix(arg, "-C") || strings.HasPrefix(arg, "-c")) && len(arg) > 2)
|
||||
}
|
||||
|
||||
func gitHasUnsafeGlobalOption(args []string) bool {
|
||||
for index := 0; index < len(args); index++ {
|
||||
arg := args[index]
|
||||
switch {
|
||||
case strings.HasPrefix(arg, "--upload-pack"):
|
||||
return true
|
||||
case arg == "-C" || strings.HasPrefix(arg, "-C"):
|
||||
return true
|
||||
case gitOptionConsumesValue(arg):
|
||||
index++
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func gitArgsReadOnly(args []string) bool {
|
||||
for _, arg := range args {
|
||||
switch {
|
||||
case strings.HasPrefix(arg, "--output="), strings.HasPrefix(arg, "--exec="):
|
||||
return false
|
||||
case arg == "--output", arg == "--exec":
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func gitBranchReadOnly(args []string) bool {
|
||||
if len(args) == 0 {
|
||||
return true
|
||||
}
|
||||
for _, arg := range args {
|
||||
if strings.HasPrefix(arg, "--format=") {
|
||||
continue
|
||||
}
|
||||
switch arg {
|
||||
case "--list", "-l", "--show-current", "-a", "--all", "-r", "--remotes", "-v", "-vv", "--verbose":
|
||||
continue
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func commandName(raw string) string {
|
||||
name := strings.TrimSpace(raw)
|
||||
if index := strings.LastIndexAny(name, `/\`); index >= 0 {
|
||||
name = name[index+1:]
|
||||
}
|
||||
name = strings.ToLower(name)
|
||||
for _, suffix := range []string{".exe", ".cmd", ".bat", ".com"} {
|
||||
if strings.HasSuffix(name, suffix) {
|
||||
return strings.TrimSuffix(name, suffix)
|
||||
}
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
func hasStringPrefix(values []string, prefix []string) bool {
|
||||
if len(prefix) == 0 || len(prefix) > len(values) {
|
||||
return false
|
||||
}
|
||||
for index := range prefix {
|
||||
if values[index] != prefix[index] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func equalStringSlices(left []string, right []string) bool {
|
||||
if len(left) != len(right) {
|
||||
return false
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
)
|
||||
import "testing"
|
||||
|
||||
func TestProposedCommandPrefixUsesSafeSimpleCommands(t *testing.T) {
|
||||
got := proposedCommandPrefix("bash", map[string]any{"command": "go test ./..."})
|
||||
@@ -33,35 +29,6 @@ func TestProposedCommandPrefixHonorsValidatedRequestedPrefix(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProposedCommandPrefixSupportsSegmentedCommands(t *testing.T) {
|
||||
got := proposedCommandPrefix("bash", map[string]any{"command": "ps aux | head -5"})
|
||||
want := []string{"ps", "aux"}
|
||||
if !equalStringSlices(got, want) {
|
||||
t.Fatalf("prefix = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProposedCommandPrefixHonorsRequestedPrefixAcrossSegments(t *testing.T) {
|
||||
got := proposedCommandPrefix("bash", map[string]any{
|
||||
"command": "git status --short && git status --branch",
|
||||
"prefix_rule": []any{"git", "status"},
|
||||
})
|
||||
want := []string{"git", "status"}
|
||||
if !equalStringSlices(got, want) {
|
||||
t.Fatalf("prefix = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProposedCommandPrefixRejectsRequestedPrefixThatDoesNotCoverSegments(t *testing.T) {
|
||||
got := proposedCommandPrefix("bash", map[string]any{
|
||||
"command": "ps aux && npm install",
|
||||
"prefix_rule": []any{"ps", "aux"},
|
||||
})
|
||||
if got != nil {
|
||||
t.Fatalf("partial requested prefix should be rejected, got %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProposedCommandPrefixRejectsUnsafeRequestedPrefix(t *testing.T) {
|
||||
got := proposedCommandPrefix("bash", map[string]any{
|
||||
"command": "git status --short",
|
||||
@@ -74,6 +41,7 @@ func TestProposedCommandPrefixRejectsUnsafeRequestedPrefix(t *testing.T) {
|
||||
|
||||
func TestProposedCommandPrefixRejectsUnsafeShellForms(t *testing.T) {
|
||||
cases := []string{
|
||||
"echo hi && echo bye",
|
||||
"cat < in > out",
|
||||
"FOO=bar go test",
|
||||
"echo $(whoami)",
|
||||
@@ -117,25 +85,6 @@ func TestProposedCommandPrefixRejectsUnsafeLaunchers(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchCommandPrefixCoversSegmentedCommandWithSafeTail(t *testing.T) {
|
||||
engine := sandbox.NewEngine(sandbox.EngineOptions{WorkspaceRoot: t.TempDir()})
|
||||
engine.GrantCommandPrefixForSession("bash", []string{"ps", "aux"})
|
||||
|
||||
grant, ok, session := matchCommandPrefix("bash", map[string]any{"command": "ps aux | head -5"}, Options{Sandbox: engine})
|
||||
if !ok || !session || !equalStringSlices(grant.Prefix, []string{"ps", "aux"}) {
|
||||
t.Fatalf("match = %#v ok=%v session=%v, want session ps aux prefix", grant, ok, session)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchCommandPrefixRejectsUncoveredSegment(t *testing.T) {
|
||||
engine := sandbox.NewEngine(sandbox.EngineOptions{WorkspaceRoot: t.TempDir()})
|
||||
engine.GrantCommandPrefixForSession("bash", []string{"ps", "aux"})
|
||||
|
||||
if grant, ok, session := matchCommandPrefix("bash", map[string]any{"command": "ps aux && npm install"}, Options{Sandbox: engine}); ok {
|
||||
t.Fatalf("match = %#v session=%v, want no match because npm segment is uncovered", grant, session)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProposedCommandPrefixRejectsRequestedUnsafeLauncherPrefix(t *testing.T) {
|
||||
got := proposedCommandPrefix("bash", map[string]any{
|
||||
"command": "find . -type f",
|
||||
|
||||
@@ -1,205 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
// someRequestContains reports whether any message sent to the provider across all
|
||||
// turns contains substr — used to assert a continue nudge was actually injected.
|
||||
func someRequestContains(requests []zeroruntime.CompletionRequest, substr string) bool {
|
||||
for _, req := range requests {
|
||||
for _, msg := range req.Messages {
|
||||
if strings.Contains(msg.Content, substr) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// planTurn is a turn that calls update_plan with the given item statuses (reusing
|
||||
// the package's shared toolTurn helper).
|
||||
func planTurn(statuses ...string) []zeroruntime.StreamEvent {
|
||||
items := make([]string, len(statuses))
|
||||
for i, s := range statuses {
|
||||
items[i] = `{"content":"step ` + s + `","status":"` + s + `"}`
|
||||
}
|
||||
return toolTurn("plan", "update_plan", `{"plan":[`+strings.Join(items, ",")+`]}`)
|
||||
}
|
||||
|
||||
// BUG #1 regression: a no-tool-call turn that ends mid-step while plan items are
|
||||
// still pending must NOT be accepted as success. The loop must re-prompt (bounded)
|
||||
// and, if the model keeps stalling, finalize as INCOMPLETE — never success.
|
||||
func TestCompletionGatePendingPlanContinuesThenIncomplete(t *testing.T) {
|
||||
// Mirrors the git-multibranch failure: plan with pending steps, then the model
|
||||
// keeps emitting "…Let me check the SSH configuration:" without acting.
|
||||
cue := "Now I need to configure the SSH server. Let me check the current SSH configuration:"
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewUpdatePlanTool())
|
||||
|
||||
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
|
||||
planTurn("completed", "pending", "pending"),
|
||||
textTurn(cue), textTurn(cue), textTurn(cue), textTurn(cue), textTurn(cue),
|
||||
}}
|
||||
|
||||
result, err := Run(context.Background(), "set up a git server", provider, Options{
|
||||
Registry: registry,
|
||||
MaxTurns: 10,
|
||||
RequireCompletionSignal: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !result.Incomplete {
|
||||
t.Fatalf("expected Incomplete=true (model stalled with pending plan), got false; final=%q turns=%d", result.FinalAnswer, result.Turns)
|
||||
}
|
||||
// 1 plan turn + maxContinueNudges(3) nudged turns + 1 final stalling turn = 5.
|
||||
// Critically it did NOT stop at the first text turn (request 2) as success.
|
||||
if len(provider.requests) != 1+maxContinueNudges+1 {
|
||||
t.Fatalf("expected %d provider turns (1 plan + %d nudges + 1 final), got %d",
|
||||
1+maxContinueNudges+1, maxContinueNudges, len(provider.requests))
|
||||
}
|
||||
if !someRequestContains(provider.requests, continueNudgeMarker) {
|
||||
t.Fatalf("expected a continue nudge (%q) to be injected into the conversation", continueNudgeMarker)
|
||||
}
|
||||
}
|
||||
|
||||
// A genuinely-complete single-turn answer (no plan, no continuation cue) must
|
||||
// still finalize as success — the gate must not break short/read-only tasks.
|
||||
func TestCompletionGateAcceptsGenuineCompletion(t *testing.T) {
|
||||
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
|
||||
textTurn("The file contains 42 lines."),
|
||||
}}
|
||||
|
||||
result, err := Run(context.Background(), "count the lines", provider, Options{
|
||||
Registry: tools.NewRegistry(),
|
||||
MaxTurns: 10,
|
||||
RequireCompletionSignal: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Incomplete {
|
||||
t.Fatalf("genuine completion wrongly marked Incomplete; final=%q", result.FinalAnswer)
|
||||
}
|
||||
if result.FinalAnswer != "The file contains 42 lines." {
|
||||
t.Fatalf("final answer = %q, want the completed answer", result.FinalAnswer)
|
||||
}
|
||||
if len(provider.requests) != 1 {
|
||||
t.Fatalf("expected exactly 1 turn (no spurious re-prompt), got %d", len(provider.requests))
|
||||
}
|
||||
}
|
||||
|
||||
// A continuation-cue turn triggers a re-prompt, but once the model actually
|
||||
// finishes (clean answer, no cue, no pending plan) the run exits as success — the
|
||||
// nudge gives the model a path to a legitimate completion.
|
||||
func TestCompletionGateContinuesOnCueThenSucceeds(t *testing.T) {
|
||||
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
|
||||
textTurn("Let me read the file:"),
|
||||
textTurn("Done. The file has 42 lines."),
|
||||
}}
|
||||
|
||||
result, err := Run(context.Background(), "count the lines", provider, Options{
|
||||
Registry: tools.NewRegistry(),
|
||||
MaxTurns: 10,
|
||||
RequireCompletionSignal: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Incomplete {
|
||||
t.Fatalf("run wrongly marked Incomplete after the model completed; final=%q", result.FinalAnswer)
|
||||
}
|
||||
if result.FinalAnswer != "Done. The file has 42 lines." {
|
||||
t.Fatalf("final answer = %q, want the completed answer", result.FinalAnswer)
|
||||
}
|
||||
if len(provider.requests) != 2 {
|
||||
t.Fatalf("expected 2 turns (1 cue re-prompted + 1 completion), got %d", len(provider.requests))
|
||||
}
|
||||
if !someRequestContains(provider.requests, continueNudgeMarker) {
|
||||
t.Fatalf("expected a continue nudge after the continuation-cue turn")
|
||||
}
|
||||
}
|
||||
|
||||
// With the gate OFF (the interactive/TUI default), a continuation-cue turn is
|
||||
// accepted as the final answer exactly as before — guaranteeing no behavior
|
||||
// change for non-headless callers.
|
||||
func TestCompletionGateOffPreservesLegacyBehavior(t *testing.T) {
|
||||
cue := "Let me check the config:"
|
||||
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
|
||||
textTurn(cue),
|
||||
}}
|
||||
|
||||
result, err := Run(context.Background(), "do a thing", provider, Options{
|
||||
Registry: tools.NewRegistry(),
|
||||
MaxTurns: 10,
|
||||
// RequireCompletionSignal deliberately left false.
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Incomplete {
|
||||
t.Fatalf("legacy path must never set Incomplete; final=%q", result.FinalAnswer)
|
||||
}
|
||||
if result.FinalAnswer != cue {
|
||||
t.Fatalf("final answer = %q, want %q (legacy: text-only turn is the answer)", result.FinalAnswer, cue)
|
||||
}
|
||||
if len(provider.requests) != 1 {
|
||||
t.Fatalf("legacy path must not re-prompt; got %d turns", len(provider.requests))
|
||||
}
|
||||
}
|
||||
|
||||
// review #6: the continuation-cue detector must catch a mid-line action announcement
|
||||
// that stops on a colon (the git-multibranch case), without flagging genuine closers
|
||||
// — a recommendation, a plain summary colon, or a sign-off.
|
||||
func TestContinuationCueMatching(t *testing.T) {
|
||||
cases := []struct {
|
||||
text string
|
||||
cue bool
|
||||
}{
|
||||
{"Now I need to configure the SSH server. Let me check the current SSH configuration:", true},
|
||||
{"Let me read the file:", true},
|
||||
{"Now I'll run the tests:", true},
|
||||
{"Next, I suggest reviewing the changes.", false}, // recommendation, no colon
|
||||
{"Here is the summary:", false}, // summary colon, no action lead-in
|
||||
{"Let me know if you need anything:", false}, // sign-off
|
||||
{"The function is implemented and all tests pass.", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := endsWithContinuationCue(c.text); got != c.cue {
|
||||
t.Errorf("endsWithContinuationCue(%q) = %v, want %v", c.text, got, c.cue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// review #4: a run that loops to the MaxTurns ceiling (always calling a tool, so it
|
||||
// never reaches the no-tool-call gate) was reported as success. Under the headless
|
||||
// gate, a max-turns cutoff is INCOMPLETE — the agent was stopped mid-run, not done.
|
||||
func TestMaxTurnsCutoffIsIncompleteUnderGate(t *testing.T) {
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewUpdatePlanTool())
|
||||
toolEvery := toolTurn("c", "update_plan", `{"plan":[{"content":"step","status":"in_progress"}]}`)
|
||||
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
|
||||
toolEvery, toolEvery, toolEvery, toolEvery,
|
||||
}}
|
||||
|
||||
result, err := Run(context.Background(), "keep working", provider, Options{
|
||||
Registry: registry,
|
||||
MaxTurns: 2,
|
||||
RequireCompletionSignal: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !result.Incomplete {
|
||||
t.Fatalf("a max-turns cutoff under the gate must be Incomplete; final=%q", result.FinalAnswer)
|
||||
}
|
||||
if !strings.Contains(result.IncompleteReason, "max-turns") {
|
||||
t.Fatalf("IncompleteReason = %q, want it to cite max-turns", result.IncompleteReason)
|
||||
}
|
||||
}
|
||||
@@ -81,11 +81,3 @@ Otherwise, confirm before executing.
|
||||
6. Avoid redundant confirmations if you already confirmed and there is no material new risk.
|
||||
7. Write/shell operations inside the workspace do not need the same scrutiny as operations outside it.
|
||||
8. Interactive programs (editors, pagers, REPLs, `ssh`/`psql`/`mysql` without a command, `top`/`htop`, `git rebase -i`) will be blocked because they hang the agent; always use the non-interactive alternative.
|
||||
|
||||
## Sandbox Permission Requests
|
||||
|
||||
- Prefer sandboxed additional permissions over full escalation: use `sandbox_permissions: "with_additional_permissions"` with the required file-system or network permission when that can satisfy the command.
|
||||
- Use `sandbox_permissions: "require_escalated"` only when the command must run outside the sandbox, such as inspecting or controlling host/global state that the sandbox intentionally hides.
|
||||
- If a sandboxed shell command returns only sandbox-local state when the user asked for host/global state, do not answer from that misleading output. Rerun the needed command with `sandbox_permissions: "require_escalated"`, a short user-facing `justification`, and a narrow `prefix_rule` when appropriate.
|
||||
- For `require_escalated`, include a short user-facing `justification`. When a reusable approval is appropriate, include a narrow `prefix_rule`; do not suggest broad shell, interpreter, or launcher prefixes.
|
||||
- Shell commands are evaluated as independent command segments across `|`, `&&`, `||`, and `;`. A reusable prefix must apply safely to the command segment it is meant to authorize.
|
||||
@@ -129,24 +129,6 @@ func (fakeToolSearchTool) Run(_ context.Context, _ map[string]any) tools.Result
|
||||
return tools.Result{Status: tools.StatusOK, Output: "ok"}
|
||||
}
|
||||
|
||||
func toolDefinitionByName(definitions []zeroruntime.ToolDefinition, name string) *zeroruntime.ToolDefinition {
|
||||
for i := range definitions {
|
||||
if definitions[i].Name == name {
|
||||
return &definitions[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func assertNoDeferredDiscoveryMessage(t *testing.T, request zeroruntime.CompletionRequest) {
|
||||
t.Helper()
|
||||
for _, message := range request.Messages {
|
||||
if message.Role == zeroruntime.MessageRoleUser && strings.Contains(message.Content, "Deferred tools:") {
|
||||
t.Fatalf("deferred discovery must not be appended as a user message: %q", message.Content)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPartitionToolsInactiveIsByteIdenticalAndDropsToolSearch(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
@@ -237,8 +219,8 @@ func TestPartitionToolsBelowThresholdInactive(t *testing.T) {
|
||||
// FIX 6(a): a deferred tool removed by DisabledTools must NOT count toward the
|
||||
// eligible total. With N deferred registered and threshold == N, disabling one
|
||||
// drops the surviving eligible count to N-1 < threshold, so the partition takes
|
||||
// the INACTIVE path (empty discovery text, all VISIBLE tools exposed with full
|
||||
// schemas, the disabled one filtered out entirely).
|
||||
// the INACTIVE path (empty reminder, all VISIBLE tools exposed with full schemas,
|
||||
// the disabled one filtered out entirely).
|
||||
func TestPartitionToolsDisabledDeferredDropsBelowThresholdInactive(t *testing.T) {
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(fakeDeferredTool{name: "mcp__srv__alpha", desc: "alpha"})
|
||||
@@ -264,9 +246,9 @@ func TestPartitionToolsDisabledDeferredDropsBelowThresholdInactive(t *testing.T)
|
||||
}
|
||||
|
||||
// FIX 6(b): with deferral ACTIVE, a DisabledTools-hidden deferred tool must never
|
||||
// influence discovery NOR be exposed — it is filtered out before the partition
|
||||
// appear in the reminder NOR be exposed — it is filtered out before the partition
|
||||
// even considers it.
|
||||
func TestPartitionToolsActiveExcludesDisabledDeferredFromDiscoveryAndExposed(t *testing.T) {
|
||||
func TestPartitionToolsActiveExcludesDisabledDeferredFromReminderAndExposed(t *testing.T) {
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(fakeDeferredTool{name: "mcp__srv__alpha", desc: "alpha"})
|
||||
registry.Register(fakeDeferredTool{name: "mcp__srv__beta", desc: "beta"})
|
||||
@@ -274,31 +256,25 @@ func TestPartitionToolsActiveExcludesDisabledDeferredFromDiscoveryAndExposed(t *
|
||||
registry.Register(fakeToolSearchTool{}) // usable loader => deferral can activate
|
||||
|
||||
// 3 deferred, disable beta => 2 surviving eligible, threshold 2 => active.
|
||||
exposed, discovery := partitionTools(registry, PermissionModeAuto, Options{
|
||||
exposed, reminder := partitionTools(registry, PermissionModeAuto, Options{
|
||||
DeferThreshold: 2,
|
||||
DisabledTools: []string{"mcp__srv__beta"},
|
||||
}, map[string]bool{})
|
||||
|
||||
if discovery == "" {
|
||||
t.Fatalf("expected active path with discovery text for the unloaded tools")
|
||||
if reminder == "" {
|
||||
t.Fatalf("expected active path with a non-empty reminder for the unloaded tools")
|
||||
}
|
||||
if strings.Contains(discovery, "mcp__srv__beta") {
|
||||
t.Fatalf("disabled deferred tool must not appear in discovery text, got %q", discovery)
|
||||
if strings.Contains(reminder, "mcp__srv__beta") {
|
||||
t.Fatalf("disabled deferred tool must not appear in the reminder, got %q", reminder)
|
||||
}
|
||||
for _, def := range exposed {
|
||||
if def.Name == "mcp__srv__beta" {
|
||||
t.Fatalf("disabled deferred tool must not be exposed, got %#v", exposed)
|
||||
}
|
||||
}
|
||||
search := toolDefinitionByName(exposed, tools.ToolSearchToolName)
|
||||
if search == nil {
|
||||
t.Fatalf("tool_search must be exposed on active path, got %#v", exposed)
|
||||
}
|
||||
if search.Description != discovery {
|
||||
t.Fatalf("tool_search description must carry discovery text\n got: %q\nwant: %q", search.Description, discovery)
|
||||
}
|
||||
if !strings.Contains(discovery, "mcp") {
|
||||
t.Fatalf("discovery text must list the surviving deferred source, got %q", discovery)
|
||||
// The two surviving deferred tools are hidden (unloaded) and named in the reminder.
|
||||
if !strings.Contains(reminder, "mcp__srv__alpha") || !strings.Contains(reminder, "mcp__srv__gamma") {
|
||||
t.Fatalf("reminder must list the surviving unloaded deferred tools, got %q", reminder)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -313,7 +289,7 @@ func TestPartitionToolsActiveHidesUnloadedExposesLoaded(t *testing.T) {
|
||||
loaded := map[string]bool{"mcp__srv__alpha": true}
|
||||
|
||||
// 2 eligible deferred tools, threshold 2 => active.
|
||||
exposed, discovery := partitionTools(registry, PermissionModeAuto, Options{DeferThreshold: 2}, loaded)
|
||||
exposed, reminder := partitionTools(registry, PermissionModeAuto, Options{DeferThreshold: 2}, loaded)
|
||||
|
||||
exposedNames := map[string]bool{}
|
||||
for _, def := range exposed {
|
||||
@@ -331,21 +307,14 @@ func TestPartitionToolsActiveHidesUnloadedExposesLoaded(t *testing.T) {
|
||||
if exposedNames["mcp__srv__beta"] {
|
||||
t.Fatalf("unloaded deferred tool must be hidden from exposed, got %#v", exposed)
|
||||
}
|
||||
if discovery == "" {
|
||||
t.Fatalf("expected non-empty discovery text for the hidden tool")
|
||||
if reminder == "" {
|
||||
t.Fatalf("expected a non-empty reminder for the hidden tool")
|
||||
}
|
||||
search := toolDefinitionByName(exposed, tools.ToolSearchToolName)
|
||||
if search == nil {
|
||||
t.Fatalf("expected tool_search exposed on active path, got %#v", exposed)
|
||||
if !strings.Contains(reminder, "mcp__srv__beta") {
|
||||
t.Fatalf("expected reminder to list the hidden tool, got %q", reminder)
|
||||
}
|
||||
if search.Description != discovery || !strings.Contains(search.Description, "mcp") {
|
||||
t.Fatalf("tool_search description must carry discovery source, got %q", search.Description)
|
||||
}
|
||||
if strings.Contains(search.Description, "mcp__srv__alpha") {
|
||||
t.Fatalf("tool_search description must not list already-loaded tool names, got %q", search.Description)
|
||||
}
|
||||
if !strings.Contains(search.Description, "mcp__srv__beta") {
|
||||
t.Fatalf("tool_search description must list exact hidden tool names, got %q", search.Description)
|
||||
if strings.Contains(reminder, "mcp__srv__alpha") {
|
||||
t.Fatalf("loaded tool must not appear in the reminder, got %q", reminder)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -368,9 +337,9 @@ func TestPartitionToolsActiveNothingHiddenEmptyReminder(t *testing.T) {
|
||||
if !exposedNames["tool_search"] {
|
||||
t.Fatalf("expected tool_search exposed on active path, got %#v", exposed)
|
||||
}
|
||||
// No hidden tools means no dynamic discovery text is needed.
|
||||
// BuildDeferredReminder returns "" for no hidden lines.
|
||||
if reminder != "" {
|
||||
t.Fatalf("expected empty discovery text when nothing is hidden, got %q", reminder)
|
||||
t.Fatalf("expected empty reminder when nothing is hidden, got %q", reminder)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -407,8 +376,7 @@ func TestRunLoadsDeferredToolThenAdvertisesNextTurn(t *testing.T) {
|
||||
t.Fatalf("expected two turns, got %d", len(provider.requests))
|
||||
}
|
||||
|
||||
// Turn 1: alpha is hidden (not advertised), and discovery lives on the
|
||||
// tool_search definition rather than a trailing user message.
|
||||
// Turn 1: alpha is hidden (not advertised) and the reminder is a trailing user msg.
|
||||
turn1Tools := map[string]bool{}
|
||||
for _, def := range provider.requests[0].Tools {
|
||||
turn1Tools[def.Name] = true
|
||||
@@ -416,13 +384,9 @@ func TestRunLoadsDeferredToolThenAdvertisesNextTurn(t *testing.T) {
|
||||
if turn1Tools["mcp__srv__alpha"] {
|
||||
t.Fatalf("turn 1 must not advertise the not-yet-loaded deferred tool")
|
||||
}
|
||||
assertNoDeferredDiscoveryMessage(t, provider.requests[0])
|
||||
search1 := toolDefinitionByName(provider.requests[0].Tools, tools.ToolSearchToolName)
|
||||
if search1 == nil {
|
||||
t.Fatalf("turn 1 must expose tool_search, got %#v", provider.requests[0].Tools)
|
||||
}
|
||||
if !strings.Contains(search1.Description, "Tool discovery") || !strings.Contains(search1.Description, "mcp") {
|
||||
t.Fatalf("tool_search description must carry deferred discovery, got %q", search1.Description)
|
||||
last1 := provider.requests[0].Messages[len(provider.requests[0].Messages)-1]
|
||||
if last1.Role != zeroruntime.MessageRoleUser || !strings.Contains(last1.Content, "tool_search") {
|
||||
t.Fatalf("turn 1 request must end with the deferred-tools reminder, got role=%s content=%q", last1.Role, last1.Content)
|
||||
}
|
||||
|
||||
// Turn 2: alpha is now loaded and advertised with a full schema.
|
||||
@@ -437,20 +401,20 @@ func TestRunLoadsDeferredToolThenAdvertisesNextTurn(t *testing.T) {
|
||||
t.Fatalf("beta was never loaded; it must stay hidden in turn 2")
|
||||
}
|
||||
|
||||
// The discovery text must NOT persist into the returned message history.
|
||||
// The reminder must NOT persist into the returned message history.
|
||||
for _, m := range result.Messages {
|
||||
if m.Role == zeroruntime.MessageRoleUser && strings.Contains(m.Content, "Tool discovery") {
|
||||
t.Fatalf("deferred discovery must not be persisted in result.Messages")
|
||||
if m.Role == zeroruntime.MessageRoleUser && strings.Contains(m.Content, "Call tool_search") {
|
||||
t.Fatalf("the deferred-tools reminder must not be persisted in result.Messages")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunReactiveRetryKeepsLoadedDeferredToolAndDiscovery drives a mid-run
|
||||
// TestRunReactiveRetryKeepsLoadedDeferredToolAndReminder drives a mid-run
|
||||
// context-limit error that triggers reactive compaction+retry while deferral is
|
||||
// ACTIVE and a deferred tool is already loaded. The retried turn must NOT be
|
||||
// degraded to the empty-loaded/no-discovery state: it must still advertise the
|
||||
// loaded tool's FULL schema and keep tool_search discovery for hidden tools.
|
||||
func TestRunReactiveRetryKeepsLoadedDeferredToolAndDiscovery(t *testing.T) {
|
||||
// degraded to the empty-loaded/no-reminder state: it must still advertise the
|
||||
// loaded tool's FULL schema and carry the <system-reminder> for the hidden tool.
|
||||
func TestRunReactiveRetryKeepsLoadedDeferredToolAndReminder(t *testing.T) {
|
||||
registry := tools.NewRegistry()
|
||||
// load_signal asks the loop to load mcp__srv__alpha next turn.
|
||||
registry.Register(loadSignalTool{value: "mcp__srv__alpha"})
|
||||
@@ -528,20 +492,24 @@ func TestRunReactiveRetryKeepsLoadedDeferredToolAndDiscovery(t *testing.T) {
|
||||
t.Fatalf("retry must advertise the loaded tool's FULL schema, got %#v", alpha.Parameters)
|
||||
}
|
||||
|
||||
assertNoDeferredDiscoveryMessage(t, retry)
|
||||
search := toolDefinitionByName(retry.Tools, tools.ToolSearchToolName)
|
||||
if search == nil {
|
||||
t.Fatalf("retry must expose tool_search for still-hidden tools, got %#v", retry.Tools)
|
||||
// The retry must carry the deferred-tools reminder as its trailing user
|
||||
// message (the hidden tool is mcp__srv__beta) — not the no-reminder state.
|
||||
last := retry.Messages[len(retry.Messages)-1]
|
||||
if last.Role != zeroruntime.MessageRoleUser || !strings.Contains(last.Content, "tool_search") {
|
||||
t.Fatalf("retry request must end with the deferred-tools reminder, got role=%s content=%q", last.Role, last.Content)
|
||||
}
|
||||
if !strings.Contains(search.Description, "Tool discovery") || !strings.Contains(search.Description, "mcp") {
|
||||
t.Fatalf("retry tool_search description must carry deferred discovery, got %q", search.Description)
|
||||
if !strings.Contains(last.Content, "mcp__srv__beta") {
|
||||
t.Fatalf("retry reminder must list the still-hidden tool mcp__srv__beta, got %q", last.Content)
|
||||
}
|
||||
if strings.Contains(last.Content, "mcp__srv__alpha") {
|
||||
t.Fatalf("loaded tool must not appear in the retry reminder, got %q", last.Content)
|
||||
}
|
||||
|
||||
// The discovery text must NOT persist into the returned message history, even on
|
||||
// The reminder must NOT persist into the returned message history, even on
|
||||
// the reactive-retry path.
|
||||
for _, m := range result.Messages {
|
||||
if m.Role == zeroruntime.MessageRoleUser && strings.Contains(m.Content, "Tool discovery") {
|
||||
t.Fatalf("deferred discovery must not be persisted in result.Messages")
|
||||
if m.Role == zeroruntime.MessageRoleUser && strings.Contains(m.Content, "Call tool_search") {
|
||||
t.Fatalf("the deferred-tools reminder must not be persisted in result.Messages")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -576,15 +544,15 @@ func (provider *connectErrorProvider) StreamCompletion(_ context.Context, reques
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
// TestRunConnectTimeReactiveRetryKeepsLoadedDeferredToolAndDiscovery mirrors
|
||||
// TestRunReactiveRetryKeepsLoadedDeferredToolAndDiscovery but drives the
|
||||
// TestRunConnectTimeReactiveRetryKeepsLoadedDeferredToolAndReminder mirrors
|
||||
// TestRunReactiveRetryKeepsLoadedDeferredToolAndReminder but drives the
|
||||
// CONNECT-TIME error path: StreamCompletion itself returns a non-nil
|
||||
// context-limit error (rather than a mid-stream StreamEventError). With deferral
|
||||
// ACTIVE and a deferred tool already loaded, the rebuilt retry request must still
|
||||
// advertise the loaded tool's FULL schema AND carry tool_search discovery —
|
||||
// i.e. the first reactive block reuses exposed, not the empty-loaded
|
||||
// advertise the loaded tool's FULL schema AND carry the deferred-tools reminder —
|
||||
// i.e. the first reactive block reuses exposed/reminder, not the empty-loaded
|
||||
// partition.
|
||||
func TestRunConnectTimeReactiveRetryKeepsLoadedDeferredToolAndDiscovery(t *testing.T) {
|
||||
func TestRunConnectTimeReactiveRetryKeepsLoadedDeferredToolAndReminder(t *testing.T) {
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(loadSignalTool{value: "mcp__srv__alpha"})
|
||||
registry.Register(fakeDeferredTool{name: "mcp__srv__alpha", desc: "alpha tool"})
|
||||
@@ -657,19 +625,22 @@ func TestRunConnectTimeReactiveRetryKeepsLoadedDeferredToolAndDiscovery(t *testi
|
||||
t.Fatalf("retry must advertise the loaded tool's FULL schema, got %#v", alpha.Parameters)
|
||||
}
|
||||
|
||||
assertNoDeferredDiscoveryMessage(t, retry)
|
||||
search := toolDefinitionByName(retry.Tools, tools.ToolSearchToolName)
|
||||
if search == nil {
|
||||
t.Fatalf("retry must expose tool_search for still-hidden tools, got %#v", retry.Tools)
|
||||
// The retry must carry the deferred-tools reminder as its trailing user message.
|
||||
last := retry.Messages[len(retry.Messages)-1]
|
||||
if last.Role != zeroruntime.MessageRoleUser || !strings.Contains(last.Content, "tool_search") {
|
||||
t.Fatalf("retry request must end with the deferred-tools reminder, got role=%s content=%q", last.Role, last.Content)
|
||||
}
|
||||
if !strings.Contains(search.Description, "Tool discovery") || !strings.Contains(search.Description, "mcp") {
|
||||
t.Fatalf("retry tool_search description must carry deferred discovery, got %q", search.Description)
|
||||
if !strings.Contains(last.Content, "mcp__srv__beta") {
|
||||
t.Fatalf("retry reminder must list the still-hidden tool mcp__srv__beta, got %q", last.Content)
|
||||
}
|
||||
if strings.Contains(last.Content, "mcp__srv__alpha") {
|
||||
t.Fatalf("loaded tool must not appear in the retry reminder, got %q", last.Content)
|
||||
}
|
||||
|
||||
// The discovery text must NOT persist into the returned message history.
|
||||
// The reminder must NOT persist into the returned message history.
|
||||
for _, m := range result.Messages {
|
||||
if m.Role == zeroruntime.MessageRoleUser && strings.Contains(m.Content, "Tool discovery") {
|
||||
t.Fatalf("deferred discovery must not be persisted in result.Messages")
|
||||
if m.Role == zeroruntime.MessageRoleUser && strings.Contains(m.Content, "Call tool_search") {
|
||||
t.Fatalf("the deferred-tools reminder must not be persisted in result.Messages")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -678,7 +649,8 @@ func TestRunConnectTimeReactiveRetryKeepsLoadedDeferredToolAndDiscovery(t *testi
|
||||
// regression: N deferred tools are registered alongside tool_search, the operator
|
||||
// allowlists ONLY the N deferred names (NOT tool_search), and DeferThreshold == N.
|
||||
// Deferral must ACTIVATE, the partition must STILL expose tool_search (the gateway
|
||||
// to the allowlisted tools), and a tool_search call must be DISPATCH-ALLOWED.
|
||||
// to the allowlisted tools), and a tool_search call must be DISPATCH-ALLOWED — so
|
||||
// the reminder never points the model at an unreachable tool.
|
||||
func TestAllowlistedDeferredToolsKeepToolSearchReachable(t *testing.T) {
|
||||
registry := tools.NewRegistry()
|
||||
deferredNames := []string{"mcp__srv__alpha", "mcp__srv__beta"}
|
||||
@@ -695,14 +667,14 @@ func TestAllowlistedDeferredToolsKeepToolSearchReachable(t *testing.T) {
|
||||
EnabledTools: append([]string{}, deferredNames...),
|
||||
}
|
||||
|
||||
exposed, discovery := partitionTools(registry, PermissionModeAuto, options, map[string]bool{})
|
||||
exposed, reminder := partitionTools(registry, PermissionModeAuto, options, map[string]bool{})
|
||||
|
||||
// Deferral active => non-empty tool_search discovery for hidden deferred tools.
|
||||
if discovery == "" {
|
||||
t.Fatalf("expected deferral active (non-empty discovery) at threshold N, got empty")
|
||||
// Deferral active => non-empty reminder advertising the hidden deferred tools.
|
||||
if reminder == "" {
|
||||
t.Fatalf("expected deferral active (non-empty reminder) at threshold N, got empty")
|
||||
}
|
||||
if !strings.Contains(discovery, "tool_search") {
|
||||
t.Fatalf("discovery must instruct the model to call tool_search, got %q", discovery)
|
||||
if !strings.Contains(reminder, "tool_search") {
|
||||
t.Fatalf("reminder must instruct the model to call tool_search, got %q", reminder)
|
||||
}
|
||||
|
||||
// FIX 2(a): tool_search is exposed even though the allowlist omits it.
|
||||
@@ -713,10 +685,6 @@ func TestAllowlistedDeferredToolsKeepToolSearchReachable(t *testing.T) {
|
||||
if !exposedNames["tool_search"] {
|
||||
t.Fatalf("tool_search must be exposed on the active path despite the allowlist omitting it, got %#v", exposed)
|
||||
}
|
||||
search := toolDefinitionByName(exposed, tools.ToolSearchToolName)
|
||||
if search == nil || search.Description != discovery {
|
||||
t.Fatalf("tool_search description must carry discovery text, got %#v discovery=%q", search, discovery)
|
||||
}
|
||||
// The allowlisted-but-unloaded deferred tools are hidden (not exposed).
|
||||
for _, name := range deferredNames {
|
||||
if exposedNames[name] {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -45,192 +44,8 @@ const (
|
||||
// with the same error, so NO model (weak or strong) burns turns looping on a
|
||||
// bad call.
|
||||
toolFailureStopAt = 4
|
||||
|
||||
// maxContinueNudges bounds how many times the headless completion gate
|
||||
// (Options.RequireCompletionSignal) re-prompts a model that stopped without a
|
||||
// tool call while work clearly remained. Once spent, the run finalizes as
|
||||
// INCOMPLETE rather than nudging forever (and it is still bounded by maxTurns
|
||||
// and the run deadline).
|
||||
maxContinueNudges = 3
|
||||
)
|
||||
|
||||
// continueNudgeMarker is a stable substring for tests.
|
||||
const continueNudgeMarker = "the task is not finished"
|
||||
|
||||
// continueNudge tells a model that stopped without a tool call — while work
|
||||
// clearly remained — to keep going, or to mark the plan complete and summarize if
|
||||
// it is genuinely done. The second path gives it a clean route to a legitimate
|
||||
// completion (a finished plan + no continuation cue then exits as success).
|
||||
func continueNudge(reason string) string {
|
||||
return "You stopped without calling a tool, but " + continueNudgeMarker + " (" + reason + "). " +
|
||||
"Do not stop here: take the next concrete action with a tool now. " +
|
||||
"If you are genuinely finished, first mark the plan complete with update_plan, then give your final summary."
|
||||
}
|
||||
|
||||
// endsWithContinuationCue reports whether an assistant message ends mid-thought —
|
||||
// the model announcing its OWN next action and then stopping on a colon
|
||||
// ("…Let me check the SSH configuration:") rather than concluding. It requires
|
||||
// BOTH a trailing colon AND an action lead-in on the last line, so genuine closers
|
||||
// are NOT flagged: a forward-looking recommendation ("Next, I suggest reviewing
|
||||
// the changes."), a sign-off ("Let me know if you need anything"), or a summary
|
||||
// that merely ends in a colon ("Here is the summary:"). A bare trailing colon alone
|
||||
// is too common in legitimate final answers to use as a signal.
|
||||
func endsWithContinuationCue(text string) bool {
|
||||
trimmed := strings.TrimSpace(text)
|
||||
if trimmed == "" {
|
||||
return false
|
||||
}
|
||||
lines := strings.Split(trimmed, "\n")
|
||||
last := ""
|
||||
for i := len(lines) - 1; i >= 0; i-- {
|
||||
if s := strings.TrimSpace(lines[i]); s != "" {
|
||||
last = strings.ToLower(s)
|
||||
break
|
||||
}
|
||||
}
|
||||
if !strings.HasSuffix(last, ":") {
|
||||
return false
|
||||
}
|
||||
// Inspect the final clause (after the last sentence break) so a mid-line action
|
||||
// announcement is caught ("…configure the server. Let me check the config:")
|
||||
// while a plain summary colon ("Here is the summary:") or a recommendation is not.
|
||||
clause := last
|
||||
if idx := strings.LastIndex(last, ". "); idx >= 0 {
|
||||
clause = strings.TrimSpace(last[idx+2:])
|
||||
}
|
||||
if strings.HasPrefix(clause, "let me know") { // sign-off, not a mid-step action
|
||||
return false
|
||||
}
|
||||
for _, cue := range []string{
|
||||
"let me ", "let's ", "now i'll ", "now i will ", "now let me ", "let me now ",
|
||||
"i'll now ", "i will now ", "next i'll ", "next, i'll ", "first, i'll ", "first let me ",
|
||||
} {
|
||||
if strings.HasPrefix(clause, cue) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// planStatusRemaining reports whether a raw update_plan status string represents
|
||||
// unfinished work. Anything not clearly completed/failed (incl. empty/unknown,
|
||||
// which the update_plan tool coerces to "pending") counts as remaining, matching
|
||||
// that tool's own status normalization.
|
||||
func planStatusRemaining(status string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(status)) {
|
||||
case "completed", "complete", "done", "finished", "resolved", "✓", "x", "[x]",
|
||||
"failed", "fail", "error", "errored", "blocked", "cancelled", "canceled", "abandoned", "skipped":
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// acceptanceNudgeMarker is a stable substring for tests; it is embedded verbatim
|
||||
// in acceptanceVerificationNudge.
|
||||
const acceptanceNudgeMarker = "verify your work against the task's stated success criterion"
|
||||
|
||||
// selfReportPhrases are high-signal admissions of guessing/fabrication or stated
|
||||
// uncertainty about the produced result. They are matched by plain substring with
|
||||
// NO context guard, so every entry must be FIRST-PERSON or unambiguously about the
|
||||
// model's own output — NOT a phrase that also describes implemented behavior.
|
||||
// (Earlier versions included "fall back to" / "placeholder value" / "as a
|
||||
// fallback" / bare "best guess" / "without proper", which match legitimate final
|
||||
// answers like "the parser will fall back to UTF-8" or "I replaced the placeholder
|
||||
// value", wrongly downgrading completed runs — so those are removed. Admissions of
|
||||
// INABILITY are handled separately by inabilityStems, which carry a guard.)
|
||||
var selfReportPhrases = []string{
|
||||
// first-person admissions of guessing / fabrication / assumption
|
||||
"i guessed", "my best guess", "i'm guessing", "this is a guess", "just a guess",
|
||||
"i made it up", "i fabricated", "i assumed a", "i had to assume",
|
||||
// first-person lack of capability (inability stems also cover cannot/could-not)
|
||||
"i do not have the ability", "i don't have the ability", "i lack the ability",
|
||||
"no way for me to", "not possible for me to",
|
||||
// stated uncertainty about the correctness of the produced result
|
||||
"may not be correct", "might not be correct", "may be incorrect", "might be incorrect",
|
||||
"this may not work", "this might not work", "not fully functional", "not fully working",
|
||||
}
|
||||
|
||||
// inabilityStems are first-person "I cannot / can't / could not / am unable to /
|
||||
// do not have" stems. Matching the STEM (not a fixed verb) generalizes over
|
||||
// whatever action the model claims it could not perform — "analyze", "determine",
|
||||
// "do", "complete", "verify", "see", etc. — so the detector is not defeated by
|
||||
// re-phrasing (the chess case slipped a fixed "cannot analyze" list by writing
|
||||
// "…which I cannot do without proper image analysis capabilities").
|
||||
var inabilityStems = []string{
|
||||
"i cannot ", "i can't ", "i can not ", "i could not ", "i couldn't ",
|
||||
"i am unable to", "i'm unable to", "i was unable to", "i wasn't able to",
|
||||
"i was not able to", "i do not have", "i don't have", "unable to ",
|
||||
"without being able to",
|
||||
}
|
||||
|
||||
// successNegationTails are negated phrasings that indicate SUCCESS, not an
|
||||
// admission ("I could not find any remaining issues", "I cannot reproduce the
|
||||
// bug"). When an inability stem is immediately followed by one of these, it is not
|
||||
// treated as an admission, so a clean result is not misreported as INCOMPLETE.
|
||||
var successNegationTails = []string{
|
||||
"find any", "found any", "find a ", "see any", "detect any", "identify any",
|
||||
"reproduce", "spot any", "locate any",
|
||||
}
|
||||
|
||||
// selfReportedIncompletion returns a short reason when the model's final text
|
||||
// admits it guessed or could not meet the objective, else "". Case-insensitive.
|
||||
func selfReportedIncompletion(text string) string {
|
||||
lower := strings.ToLower(text)
|
||||
for _, phrase := range selfReportPhrases {
|
||||
if strings.Contains(lower, phrase) {
|
||||
return selfReportReason(phrase)
|
||||
}
|
||||
}
|
||||
for _, stem := range inabilityStems {
|
||||
// Scan EVERY occurrence of the stem, not just the first: an earlier
|
||||
// success-negation use ("I could not find any examples, so I could not
|
||||
// implement it") must not mask a later genuine admission with the same stem.
|
||||
for start := 0; ; {
|
||||
rel := strings.Index(lower[start:], stem)
|
||||
if rel < 0 {
|
||||
break
|
||||
}
|
||||
abs := start + rel
|
||||
tail := strings.TrimSpace(lower[abs+len(stem):])
|
||||
if !hasAnyPrefix(tail, successNegationTails) {
|
||||
return selfReportReason(strings.TrimSpace(stem) + " …")
|
||||
}
|
||||
start = abs + len(stem)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func selfReportReason(marker string) string {
|
||||
return `the final message admits the objective was not met ("` + marker + `")`
|
||||
}
|
||||
|
||||
func hasAnyPrefix(s string, prefixes []string) bool {
|
||||
for _, p := range prefixes {
|
||||
if strings.HasPrefix(s, p) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// acceptanceVerificationNudge forces a TASK-GROUNDED acceptance check before a run
|
||||
// may finalize as success. It explicitly rejects the three false-success patterns
|
||||
// the bug-hunt surfaced: well-formed output treated as correct; pre-existing tests
|
||||
// passing treated as the objective being met; and a result that merely matches a
|
||||
// baseline the task asked to beat or improve. General — no task-specific content.
|
||||
func acceptanceVerificationNudge() string {
|
||||
return "Before this task can be marked complete, " + acceptanceNudgeMarker + " — " +
|
||||
"NOT the shape or format of your output, NOT that pre-existing tests pass, and NOT that your " +
|
||||
"result merely matches a baseline you were asked to beat or improve. " +
|
||||
"Re-read the original task, then run a concrete check that exercises the actual requirement: " +
|
||||
"execute the program or tests that demonstrate the required behavior, or directly probe the specific " +
|
||||
"thing the task asked you to produce, recover, fix, or optimize. " +
|
||||
"If that check passes, reply PASS and cite the evidence. " +
|
||||
"If it does not pass — or you cannot run such a check — say so plainly and keep working; do not claim success."
|
||||
}
|
||||
|
||||
// toolFailureHintMarker is a stable substring for tests.
|
||||
const toolFailureHintMarker = "kept failing with the same error"
|
||||
|
||||
@@ -362,10 +177,6 @@ type guardState struct {
|
||||
staleReminderSent bool
|
||||
toolOnlyTurns int
|
||||
toolOnlyReminderSent bool
|
||||
// planItemsPending is the number of remaining (pending/in_progress) items in
|
||||
// the most recent update_plan call, so the headless completion gate can tell
|
||||
// whether work is unfinished when the model stops without a tool call.
|
||||
planItemsPending int
|
||||
// toolFailures tracks consecutive same-error failures per tool, keyed by tool
|
||||
// name, so the loop can hint then halt instead of looping forever.
|
||||
toolFailures map[string]*toolFailureRecord
|
||||
@@ -434,9 +245,6 @@ func (state *guardState) observeTurn(collected zeroruntime.CollectedStream) (sto
|
||||
state.toolCallsSincePlanUpdate = 0
|
||||
// A fresh plan update opens a new stale interval.
|
||||
state.staleReminderSent = false
|
||||
// Record how many items remain so the completion gate knows whether
|
||||
// work is unfinished if the model later stops without a tool call.
|
||||
state.observePlanUpdate(call.Arguments)
|
||||
} else {
|
||||
state.toolCallsSincePlanUpdate++
|
||||
}
|
||||
@@ -445,33 +253,6 @@ func (state *guardState) observeTurn(collected zeroruntime.CollectedStream) (sto
|
||||
return state.emptyTurns >= maxEmptyTurns
|
||||
}
|
||||
|
||||
// pendingPlanItems reports whether the most recent update_plan call still has
|
||||
// unfinished (pending/in_progress) items. False when no plan was ever recorded.
|
||||
func (state *guardState) pendingPlanItems() bool {
|
||||
return state.planItemsPending > 0
|
||||
}
|
||||
|
||||
// observePlanUpdate parses an update_plan call's raw arguments and records how
|
||||
// many items are still remaining. Malformed arguments leave the prior count
|
||||
// unchanged (best-effort — the plan panel itself tolerates the same).
|
||||
func (state *guardState) observePlanUpdate(arguments string) {
|
||||
var parsed struct {
|
||||
Plan []struct {
|
||||
Status string `json:"status"`
|
||||
} `json:"plan"`
|
||||
}
|
||||
if json.Unmarshal([]byte(arguments), &parsed) != nil {
|
||||
return
|
||||
}
|
||||
pending := 0
|
||||
for _, item := range parsed.Plan {
|
||||
if planStatusRemaining(item.Status) {
|
||||
pending++
|
||||
}
|
||||
}
|
||||
state.planItemsPending = pending
|
||||
}
|
||||
|
||||
func (state *guardState) progressReminder() string {
|
||||
if state.toolOnlyReminderSent || state.toolOnlyTurns < toolOnlyProgressReminderAt {
|
||||
return ""
|
||||
|
||||
+60
-549
@@ -130,14 +130,6 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
// loaded tool's full schema; it lives only for the run (v1 within-run scope).
|
||||
loaded := map[string]bool{}
|
||||
|
||||
// continueNudges counts how many times the headless completion gate
|
||||
// (Options.RequireCompletionSignal) has re-prompted a no-tool-call turn that
|
||||
// stopped with work still unfinished. Bounded by maxContinueNudges.
|
||||
continueNudges := 0
|
||||
// acceptanceRequested records that the one-time task-grounded acceptance check
|
||||
// has already been demanded this run, so it fires at most once.
|
||||
acceptanceRequested := false
|
||||
|
||||
result := Result{Messages: copyMessages(messages)}
|
||||
for turn := 0; turn < maxTurns; turn++ {
|
||||
result.Turns = turn + 1
|
||||
@@ -146,7 +138,7 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
// the tool-definition tokens (they ride on every request) in its estimate.
|
||||
// partitionTools depends only on registry/permissions/options/loaded, not on
|
||||
// the messages, so computing it before compaction is safe.
|
||||
exposed, _ := partitionTools(registry, permissionMode, options, loaded)
|
||||
exposed, reminder := partitionTools(registry, permissionMode, options, loaded)
|
||||
|
||||
// PROACTIVE compaction: if the history is approaching the model's
|
||||
// context window, summarize the oldest middle before building the
|
||||
@@ -157,6 +149,15 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
Tools: exposed,
|
||||
ReasoningEffort: options.ReasoningEffort,
|
||||
}
|
||||
if reminder != "" {
|
||||
// Append to the per-turn request copy only — NEVER to persistent
|
||||
// messages — so the reminder refreshes each turn and never accumulates
|
||||
// in the saved transcript.
|
||||
request.Messages = append(request.Messages, zeroruntime.Message{
|
||||
Role: zeroruntime.MessageRoleUser,
|
||||
Content: reminder,
|
||||
})
|
||||
}
|
||||
|
||||
// Report the per-category context budget for this turn so a surface can
|
||||
// show utilization. Opt-in: a no-op when OnContext is unset.
|
||||
@@ -183,15 +184,24 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
return result, retryErr
|
||||
}
|
||||
// Rebuild from the compacted messages but reuse the SAME active-mode
|
||||
// partition computed for this turn: it depends
|
||||
// partition (exposed) and reminder computed for this turn: they depend
|
||||
// on registry+loaded, not on the messages, so they stay valid after
|
||||
// compaction. Using the bare toolDefinitions here would route through an
|
||||
// empty-loaded partition, re-hiding every already-loaded deferred tool.
|
||||
// empty-loaded partition, re-hiding every already-loaded deferred tool
|
||||
// and dropping the reminder when deferral is active.
|
||||
request = zeroruntime.CompletionRequest{
|
||||
Messages: copyMessages(messages),
|
||||
Tools: exposed,
|
||||
ReasoningEffort: options.ReasoningEffort,
|
||||
}
|
||||
if reminder != "" {
|
||||
// Append to the per-turn retry copy only — NEVER to persistent
|
||||
// messages — matching the main path's reminder-not-persisted invariant.
|
||||
request.Messages = append(request.Messages, zeroruntime.Message{
|
||||
Role: zeroruntime.MessageRoleUser,
|
||||
Content: reminder,
|
||||
})
|
||||
}
|
||||
// Pre-content connect after a context-limit compaction: route through the
|
||||
// reconnect helper so a transient upstream hiccup here doesn't fail the
|
||||
// whole run and re-burn every token (AUDIT-L1).
|
||||
@@ -225,16 +235,24 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
result.Messages = copyMessages(messages)
|
||||
return result, retryErr
|
||||
}
|
||||
// Reuse the SAME active-mode partition (exposed) from this
|
||||
// turn rather than the bare toolDefinitions: exposed depends on
|
||||
// Reuse the SAME active-mode partition (exposed) and reminder from this
|
||||
// turn rather than the bare toolDefinitions: exposed/reminder depend on
|
||||
// registry+loaded (not the messages), so they stay valid after compaction.
|
||||
// Routing through an empty-loaded partition here would re-hide every
|
||||
// already-loaded deferred tool.
|
||||
// already-loaded deferred tool and drop the reminder when deferral is active.
|
||||
retryRequest := zeroruntime.CompletionRequest{
|
||||
Messages: copyMessages(messages),
|
||||
Tools: exposed,
|
||||
ReasoningEffort: options.ReasoningEffort,
|
||||
}
|
||||
if reminder != "" {
|
||||
// Append to the per-turn retry copy only — NEVER to persistent
|
||||
// messages — matching the main path's reminder-not-persisted invariant.
|
||||
retryRequest.Messages = append(retryRequest.Messages, zeroruntime.Message{
|
||||
Role: zeroruntime.MessageRoleUser,
|
||||
Content: reminder,
|
||||
})
|
||||
}
|
||||
// Pre-content reconnect of a mid-stream disconnect: route the connect
|
||||
// through the reconnect helper too (AUDIT-L1).
|
||||
retryStream, retryStreamErr := streamWithReconnect(ctx, provider, retryRequest, reconnectNoticeFor(options))
|
||||
@@ -309,72 +327,6 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
})
|
||||
continue
|
||||
}
|
||||
// Completion gate (headless): a turn with text but no tool call is the
|
||||
// model's final answer ONLY when the work is actually done. Default off
|
||||
// (RequireCompletionSignal), so interactive runs stay byte-identical.
|
||||
if options.RequireCompletionSignal {
|
||||
// (1) Self-report downgrade (strongest, unambiguous): the model's own
|
||||
// final message admits it guessed / could not meet the objective. Checked
|
||||
// FIRST so an admitted-impossible task is downgraded immediately (no wasted
|
||||
// continue-nudges) and reports the accurate reason.
|
||||
if reason := selfReportedIncompletion(collected.Text); reason != "" {
|
||||
result.Incomplete = true
|
||||
result.IncompleteReason = reason
|
||||
result.FinalAnswer = collected.Text
|
||||
result.Messages = copyMessages(messages)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// (2) The model stopped without a tool call while work may be unfinished:
|
||||
// - a continuation cue ("…Let me check the config:") is an unambiguous
|
||||
// mid-step stop;
|
||||
// - pending update_plan items are a WEAK, ambiguous signal (the model may
|
||||
// have finished without re-marking the last step).
|
||||
// Nudge to continue (bounded). After the budget: a persisted continuation
|
||||
// cue finalizes INCOMPLETE; pending-plan WITHOUT a cue does NOT (that would
|
||||
// false-fail a completed run with stale bookkeeping) — fall through to the
|
||||
// acceptance check / success.
|
||||
cue := endsWithContinuationCue(collected.Text)
|
||||
planPending := guards.pendingPlanItems()
|
||||
if cue || planPending {
|
||||
if continueNudges < maxContinueNudges {
|
||||
continueNudges++
|
||||
reason := "your message ended mid-step"
|
||||
if !cue {
|
||||
reason = "pending plan items remain — finish them, or mark them complete with update_plan if you are done"
|
||||
}
|
||||
messages = append(messages, zeroruntime.Message{
|
||||
Role: zeroruntime.MessageRoleUser,
|
||||
Content: continueNudge(reason),
|
||||
})
|
||||
continue
|
||||
}
|
||||
if cue {
|
||||
result.Incomplete = true
|
||||
result.IncompleteReason = "your message ended mid-step"
|
||||
result.FinalAnswer = collected.Text
|
||||
result.Messages = copyMessages(messages)
|
||||
return result, nil
|
||||
}
|
||||
// pending-plan only, budget spent: trust the model's completion claim
|
||||
// over stale plan bookkeeping; fall through.
|
||||
}
|
||||
|
||||
// (3) Task-grounded acceptance: before accepting a "done" turn as success,
|
||||
// require ONE acceptance check grounded in the task's stated criterion
|
||||
// (only when self-correct is on). Rejects "well-formed == correct",
|
||||
// "existing-tests-pass == objective met", and "result == baseline" false
|
||||
// successes. Bounded to a single pass; a genuine post-check completion
|
||||
// (no admission, no cue) then finalizes as success on the next turn.
|
||||
if options.SelfCorrect != nil && !acceptanceRequested {
|
||||
acceptanceRequested = true
|
||||
messages = append(messages, zeroruntime.Message{
|
||||
Role: zeroruntime.MessageRoleUser,
|
||||
Content: acceptanceVerificationNudge(),
|
||||
})
|
||||
continue
|
||||
}
|
||||
}
|
||||
result.FinalAnswer = collected.Text
|
||||
result.Messages = copyMessages(messages)
|
||||
return result, nil
|
||||
@@ -550,27 +502,15 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
result.Messages = copyMessages(messages)
|
||||
return result, ctx.Err()
|
||||
}
|
||||
// Reaching here means the loop hit the maxTurns ceiling — the agent was cut off
|
||||
// mid-run, not a natural completion (a finished run returns via the no-tool-call
|
||||
// success path before this). Under the headless completion gate that is INCOMPLETE,
|
||||
// not success, so a run that loops to the turn limit isn't reported as done.
|
||||
if answer, finalMessages, finishReason := finalAnswerAfterMaxTurns(ctx, provider, messages, options); strings.TrimSpace(answer) != "" {
|
||||
result.FinalAnswer = answer
|
||||
result.FinishReason = finishReason
|
||||
result.Messages = copyMessages(finalMessages)
|
||||
if options.RequireCompletionSignal {
|
||||
result.Incomplete = true
|
||||
result.IncompleteReason = "reached the max-turns limit without completing"
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
result.FinalAnswer = maxTurnsAnswer
|
||||
result.Messages = copyMessages(messages)
|
||||
if options.RequireCompletionSignal {
|
||||
result.Incomplete = true
|
||||
result.IncompleteReason = "reached the max-turns limit without a final answer"
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
@@ -655,7 +595,7 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal
|
||||
}
|
||||
// tool_search is the gateway to the allowlisted deferred tools, so a non-empty
|
||||
// EnabledTools allowlist that omits it must NOT reject the call — otherwise the
|
||||
// discovery tool exposed to the model rejects at dispatch time (an inescapable
|
||||
// reminder points the model at a tool the dispatch gate rejects (an inescapable
|
||||
// dead-end). The allowlist is exempted; an explicit DisabledTools entry for
|
||||
// tool_search is still honored (only the allowlist is exempted, not the denylist).
|
||||
toolSearchAllowed := call.Name == tools.ToolSearchToolName && !containsToolName(options.DisabledTools, tools.ToolSearchToolName)
|
||||
@@ -732,7 +672,7 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal
|
||||
preflightDecision = &decision
|
||||
}
|
||||
|
||||
if toolFound && options.OnPermissionRequest != nil && shouldRequestPermission(tool, args, permissionGranted, preflightDecision) {
|
||||
if toolFound && options.OnPermissionRequest != nil && shouldRequestPermission(tool, permissionGranted, preflightDecision) {
|
||||
requestEvent, ok := buildPermissionEvent(call, tool, args, permissionGranted, permissionMode, options, preflightDecision)
|
||||
if !ok {
|
||||
requestEvent = fallbackPermissionEvent(call, tool, args, permissionMode, options)
|
||||
@@ -756,13 +696,6 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal
|
||||
return deniedPermissionResult(call, reason, requestEvent), nil
|
||||
}
|
||||
permissionCleanups = append(permissionCleanups, cleanup)
|
||||
cleanup, err = grantInlineAdditionalPermissions(args, sandbox.PermissionGrantScopeTurn, options)
|
||||
if err != nil {
|
||||
reason := "failed to grant additional permissions: " + err.Error()
|
||||
emitDeniedPermission(options, call, requestEvent, reason)
|
||||
return deniedPermissionResult(call, reason, requestEvent), nil
|
||||
}
|
||||
permissionCleanups = append(permissionCleanups, cleanup)
|
||||
cleanup, err = grantFilesystemForSandboxPrompt(requestEvent, sandbox.PermissionGrantScopeTurn, options)
|
||||
if err != nil {
|
||||
reason := "failed to grant filesystem permission: " + err.Error()
|
||||
@@ -773,15 +706,7 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal
|
||||
case PermissionDecisionAllowForSession:
|
||||
permissionGranted = true
|
||||
requestEvent.DecisionAction = decision.Action
|
||||
if shellCommandAdditionalPermissionsRequested(args) {
|
||||
cleanup, err := grantInlineAdditionalPermissions(args, sandbox.PermissionGrantScopeSession, options)
|
||||
if err != nil {
|
||||
reason := "failed to grant session additional permissions: " + err.Error()
|
||||
emitDeniedPermission(options, call, requestEvent, reason)
|
||||
return deniedPermissionResult(call, reason, requestEvent), nil
|
||||
}
|
||||
permissionCleanups = append(permissionCleanups, cleanup)
|
||||
} else if networkSandboxPrompt(requestEvent) {
|
||||
if networkSandboxPrompt(requestEvent) {
|
||||
cleanup, err := grantNetworkForSandboxPrompt(requestEvent, sandbox.PermissionGrantScopeSession, options)
|
||||
if err != nil {
|
||||
reason := "failed to grant session network permission: " + err.Error()
|
||||
@@ -875,7 +800,6 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal
|
||||
return blockedByHookResult(call, outcome), nil
|
||||
}
|
||||
}
|
||||
args = shellExecutionArgsForApproval(call.Name, args, decisionAction, options)
|
||||
|
||||
// Task tool: wire progress callback so the TUI sees live tool-call events
|
||||
// from the specialist child process.
|
||||
@@ -910,18 +834,6 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal
|
||||
// The sandbox decision (if any) is returned synchronously on the Result and
|
||||
// used here for permission event building.
|
||||
})
|
||||
if retryResult, directResult, retried, action, reason, prefix, abortErr := maybeRetryUnsandboxedAfterSandboxRestriction(ctx, registry, call, tool, args, result, permissionMode, options, progressCallback); retried || directResult != nil || abortErr != nil {
|
||||
if directResult != nil {
|
||||
return *directResult, abortErr
|
||||
}
|
||||
result = retryResult
|
||||
permissionGranted = true
|
||||
decisionAction = action
|
||||
decisionReason = reason
|
||||
if len(prefix) > 0 {
|
||||
decisionCommandPrefix = append([]string(nil), prefix...)
|
||||
}
|
||||
}
|
||||
sandboxDecision := result.SandboxDecision
|
||||
if toolFound && options.OnPermission != nil {
|
||||
if event, ok := buildPermissionEvent(call, tool, args, permissionGranted, permissionMode, options, sandboxDecision); ok {
|
||||
@@ -967,279 +879,6 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal
|
||||
}, nil
|
||||
}
|
||||
|
||||
const sandboxNamespaceLimitedReason = "sandbox output is limited to the sandbox PID namespace; host/global state requires approval"
|
||||
|
||||
func maybeRetryUnsandboxedAfterSandboxRestriction(ctx context.Context, registry *tools.Registry, call ToolCall, tool tools.Tool, args map[string]any, result tools.Result, permissionMode PermissionMode, options Options, progressCallback func(streamjson.Event)) (tools.Result, *ToolResult, bool, PermissionDecisionAction, string, []string, error) {
|
||||
if retryResult, directResult, retried, action, reason, abortErr := maybeRetryWithNetworkAfterSandboxDenial(ctx, registry, call, tool, args, result, permissionMode, options, progressCallback); retried || directResult != nil || abortErr != nil {
|
||||
return retryResult, directResult, retried, action, reason, nil, abortErr
|
||||
}
|
||||
if !sandboxRestrictedShellRetryCandidate(call, args, result, options) {
|
||||
return result, nil, false, "", "", nil, nil
|
||||
}
|
||||
requestEvent := sandboxRestrictionRetryEvent(call, tool, args, permissionMode, options, result)
|
||||
request := permissionRequestFromEvent(requestEvent, args, options)
|
||||
if permissionMode == PermissionModeUnsafe {
|
||||
retryArgs := unsandboxedRetryArgs(args)
|
||||
retry := runToolForUnsandboxedRetry(ctx, registry, call.Name, call.ID, retryArgs, permissionMode, options, progressCallback)
|
||||
return retry, nil, true, PermissionDecisionAllow, "unsafe permission mode permits unsandboxed retry", nil, nil
|
||||
}
|
||||
decision, err := requestPermission(ctx, request, options)
|
||||
if err != nil {
|
||||
decision = PermissionDecision{Action: PermissionDecisionDeny, Reason: err.Error()}
|
||||
}
|
||||
decision.Action = normalizePermissionDecisionAction(decision.Action)
|
||||
reason := strings.TrimSpace(decision.Reason)
|
||||
if reason == "" {
|
||||
reason = request.Reason
|
||||
}
|
||||
switch decision.Action {
|
||||
case PermissionDecisionAllow, PermissionDecisionAllowStrict, PermissionDecisionAllowForSession, PermissionDecisionAllowPrefix, PermissionDecisionAlwaysAllowPrefix:
|
||||
prefix := []string(nil)
|
||||
if decision.Action == PermissionDecisionAllowPrefix || decision.Action == PermissionDecisionAlwaysAllowPrefix {
|
||||
if len(request.CommandPrefix) == 0 {
|
||||
emitDeniedPermission(options, call, requestEvent, reason)
|
||||
denied := deniedPermissionResult(call, reason, requestEvent)
|
||||
return result, &denied, true, decision.Action, reason, nil, nil
|
||||
}
|
||||
prefix = append([]string(nil), request.CommandPrefix...)
|
||||
if options.Sandbox != nil {
|
||||
if decision.Action == PermissionDecisionAlwaysAllowPrefix {
|
||||
if grant, err := persistCommandPrefixGrant(call.Name, prefix, reason, options); err == nil {
|
||||
prefix = append([]string(nil), grant.Prefix...)
|
||||
}
|
||||
} else {
|
||||
options.Sandbox.GrantCommandPrefixForSession(call.Name, prefix)
|
||||
}
|
||||
}
|
||||
}
|
||||
retryArgs := unsandboxedRetryArgs(args)
|
||||
retry := runToolForUnsandboxedRetry(ctx, registry, call.Name, call.ID, retryArgs, permissionMode, options, progressCallback)
|
||||
return retry, nil, true, decision.Action, reason, prefix, nil
|
||||
case PermissionDecisionCancel:
|
||||
emitCanceledPermission(options, call, requestEvent, reason)
|
||||
canceled := canceledPermissionResult(call, reason, requestEvent)
|
||||
return result, &canceled, true, decision.Action, reason, nil, fmt.Errorf("%w for %s", errPermissionApprovalCanceled, call.Name)
|
||||
default:
|
||||
emitDeniedPermission(options, call, requestEvent, reason)
|
||||
denied := deniedPermissionResult(call, reason, requestEvent)
|
||||
return result, &denied, true, decision.Action, reason, nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
func maybeRetryWithNetworkAfterSandboxDenial(ctx context.Context, registry *tools.Registry, call ToolCall, tool tools.Tool, args map[string]any, result tools.Result, permissionMode PermissionMode, options Options, progressCallback func(streamjson.Event)) (tools.Result, *ToolResult, bool, PermissionDecisionAction, string, error) {
|
||||
if !sandboxDeniedNetworkRetryCandidate(call, args, result, options) {
|
||||
return result, nil, false, "", "", nil
|
||||
}
|
||||
requestEvent := sandboxDeniedNetworkRetryEvent(call, tool, args, permissionMode, options, result)
|
||||
request := permissionRequestFromEvent(requestEvent, args, options)
|
||||
if permissionMode == PermissionModeUnsafe {
|
||||
retry := runToolForNetworkRetry(ctx, registry, call.Name, call.ID, args, permissionMode, options, progressCallback)
|
||||
return retry, nil, true, PermissionDecisionAllow, "unsafe permission mode permits sandbox network retry", nil
|
||||
}
|
||||
decision, err := requestPermission(ctx, request, options)
|
||||
if err != nil {
|
||||
decision = PermissionDecision{Action: PermissionDecisionDeny, Reason: err.Error()}
|
||||
}
|
||||
decision.Action = normalizePermissionDecisionAction(decision.Action)
|
||||
reason := strings.TrimSpace(decision.Reason)
|
||||
if reason == "" {
|
||||
reason = request.Reason
|
||||
}
|
||||
switch decision.Action {
|
||||
case PermissionDecisionAllow, PermissionDecisionAllowStrict, PermissionDecisionAllowForSession:
|
||||
scope := sandbox.PermissionGrantScopeTurn
|
||||
if decision.Action == PermissionDecisionAllowForSession {
|
||||
scope = sandbox.PermissionGrantScopeSession
|
||||
}
|
||||
cleanup, err := grantNetworkForSandboxPrompt(requestEvent, scope, options)
|
||||
if err != nil {
|
||||
reason := "failed to grant network permission: " + err.Error()
|
||||
emitDeniedPermission(options, call, requestEvent, reason)
|
||||
denied := deniedPermissionResult(call, reason, requestEvent)
|
||||
return result, &denied, true, decision.Action, reason, nil
|
||||
}
|
||||
if cleanup != nil {
|
||||
defer cleanup()
|
||||
}
|
||||
retry := runToolForNetworkRetry(ctx, registry, call.Name, call.ID, args, permissionMode, options, progressCallback)
|
||||
return retry, nil, true, decision.Action, reason, nil
|
||||
case PermissionDecisionCancel:
|
||||
emitCanceledPermission(options, call, requestEvent, reason)
|
||||
canceled := canceledPermissionResult(call, reason, requestEvent)
|
||||
return result, &canceled, true, decision.Action, reason, fmt.Errorf("%w for %s", errPermissionApprovalCanceled, call.Name)
|
||||
default:
|
||||
emitDeniedPermission(options, call, requestEvent, reason)
|
||||
denied := deniedPermissionResult(call, reason, requestEvent)
|
||||
return result, &denied, true, decision.Action, reason, nil
|
||||
}
|
||||
}
|
||||
|
||||
func sandboxDeniedNetworkRetryCandidate(call ToolCall, args map[string]any, result tools.Result, options Options) bool {
|
||||
if options.Sandbox == nil || !isShellCommandTool(call.Name) {
|
||||
return false
|
||||
}
|
||||
if result.Meta[tools.SandboxLikelyDeniedMeta] != "true" || result.Meta[tools.SandboxDenialKindMeta] != tools.SandboxDenialKindNetwork {
|
||||
return false
|
||||
}
|
||||
if value, _ := args["sandbox_permissions"].(string); strings.TrimSpace(value) == string(tools.SandboxPermissionsRequireEscalated) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func sandboxDeniedNetworkRetryEvent(call ToolCall, tool tools.Tool, args map[string]any, permissionMode PermissionMode, options Options, result tools.Result) PermissionEvent {
|
||||
risk := sandbox.Classify(sandbox.Request{
|
||||
ToolName: call.Name,
|
||||
SideEffect: sandbox.SideEffect(tool.Safety().SideEffect),
|
||||
Permission: sandbox.Permission(tool.Safety().Permission),
|
||||
PermissionGranted: false,
|
||||
PermissionMode: sandbox.PermissionMode(permissionMode),
|
||||
Args: args,
|
||||
Reason: sandbox.ReasonNetworkBlocked,
|
||||
})
|
||||
risk = ensureRiskCategory(risk, "network", sandbox.RiskCritical)
|
||||
reason := sandbox.ReasonNetworkBlocked
|
||||
if keyword := strings.TrimSpace(result.Meta[tools.SandboxDenialKeywordMeta]); keyword != "" {
|
||||
reason += " (" + keyword + ")"
|
||||
}
|
||||
return PermissionEvent{
|
||||
ToolCallID: call.ID,
|
||||
ToolName: call.Name,
|
||||
Action: PermissionActionPrompt,
|
||||
Permission: string(tools.PermissionPrompt),
|
||||
PermissionMode: permissionMode,
|
||||
Autonomy: options.Autonomy,
|
||||
SideEffect: string(tools.SideEffectShell),
|
||||
Reason: sandbox.ReasonNetworkBlocked,
|
||||
DecisionReason: reason,
|
||||
Scope: permissionScope(call.Name, args),
|
||||
Risk: risk,
|
||||
CommandPrefix: proposedCommandPrefix(call.Name, args),
|
||||
}
|
||||
}
|
||||
|
||||
func sandboxRestrictedShellRetryCandidate(call ToolCall, args map[string]any, result tools.Result, options Options) bool {
|
||||
if options.Sandbox == nil || !isShellCommandTool(call.Name) {
|
||||
return false
|
||||
}
|
||||
if !options.Sandbox.UnsandboxedExecutionAllowed() {
|
||||
return false
|
||||
}
|
||||
if value, _ := args["sandbox_permissions"].(string); strings.TrimSpace(value) == string(tools.SandboxPermissionsRequireEscalated) {
|
||||
return false
|
||||
}
|
||||
return sandboxDeniedShellResult(result) || sandboxNamespaceLimitedShellResult(result)
|
||||
}
|
||||
|
||||
func sandboxDeniedShellResult(result tools.Result) bool {
|
||||
return result.Status == tools.StatusError && result.Meta[tools.SandboxLikelyDeniedMeta] == "true"
|
||||
}
|
||||
|
||||
func sandboxNamespaceLimitedShellResult(result tools.Result) bool {
|
||||
if result.Status != tools.StatusOK {
|
||||
return false
|
||||
}
|
||||
if result.Meta["sandbox_wrapped"] != "true" {
|
||||
return false
|
||||
}
|
||||
if result.Meta["sandbox_backend"] != string(sandbox.BackendLinuxBwrap) && result.Meta["sandbox_target_backend"] != string(sandbox.BackendLinuxBwrap) {
|
||||
return false
|
||||
}
|
||||
output := strings.ToLower(result.Output)
|
||||
return strings.Contains(output, "bwrap ") &&
|
||||
strings.Contains(output, "--unshare-pid") &&
|
||||
strings.Contains(output, "-- /bin/sh -c")
|
||||
}
|
||||
|
||||
func sandboxRestrictionRetryEvent(call ToolCall, tool tools.Tool, args map[string]any, permissionMode PermissionMode, options Options, result tools.Result) PermissionEvent {
|
||||
reason := sandboxRestrictionRetryReason(result)
|
||||
if keyword := strings.TrimSpace(result.Meta[tools.SandboxDenialKeywordMeta]); keyword != "" {
|
||||
reason += " (" + keyword + ")"
|
||||
}
|
||||
risk := sandbox.Classify(sandbox.Request{
|
||||
ToolName: call.Name,
|
||||
SideEffect: sandbox.SideEffect(tool.Safety().SideEffect),
|
||||
Permission: sandbox.Permission(tool.Safety().Permission),
|
||||
PermissionGranted: false,
|
||||
PermissionMode: sandbox.PermissionMode(permissionMode),
|
||||
Args: args,
|
||||
Reason: reason,
|
||||
})
|
||||
return PermissionEvent{
|
||||
ToolCallID: call.ID,
|
||||
ToolName: call.Name,
|
||||
Action: PermissionActionPrompt,
|
||||
Permission: string(tools.PermissionPrompt),
|
||||
PermissionMode: permissionMode,
|
||||
Autonomy: options.Autonomy,
|
||||
SideEffect: string(tools.SideEffectShell),
|
||||
Reason: reason,
|
||||
Scope: permissionScope(call.Name, args),
|
||||
Risk: risk,
|
||||
CommandPrefix: proposedCommandPrefix(call.Name, args),
|
||||
}
|
||||
}
|
||||
|
||||
func sandboxRestrictionRetryReason(result tools.Result) string {
|
||||
if sandboxDeniedShellResult(result) {
|
||||
if reason := strings.TrimSpace(result.Meta[tools.SandboxDenialReasonMeta]); reason != "" {
|
||||
return reason
|
||||
}
|
||||
return "sandbox blocked command execution"
|
||||
}
|
||||
if sandboxNamespaceLimitedShellResult(result) {
|
||||
return sandboxNamespaceLimitedReason
|
||||
}
|
||||
return "sandbox blocked command execution"
|
||||
}
|
||||
|
||||
func runToolForNetworkRetry(ctx context.Context, registry *tools.Registry, name string, toolCallID string, args map[string]any, permissionMode PermissionMode, options Options, progressCallback func(streamjson.Event)) tools.Result {
|
||||
return registry.RunWithOptions(ctx, name, cloneArgs(args), tools.RunOptions{
|
||||
PermissionGranted: true,
|
||||
PermissionMode: string(permissionMode),
|
||||
Autonomy: options.Autonomy,
|
||||
Sandbox: options.Sandbox,
|
||||
ToolCallID: toolCallID,
|
||||
SessionID: options.SessionID,
|
||||
Model: options.Model,
|
||||
ReasoningEffort: options.ReasoningEffort,
|
||||
Depth: options.Depth,
|
||||
Cwd: options.Cwd,
|
||||
FileTracker: options.FileTracker,
|
||||
EnabledTools: options.EnabledTools,
|
||||
DisabledTools: options.DisabledTools,
|
||||
Progress: progressCallback,
|
||||
})
|
||||
}
|
||||
|
||||
func unsandboxedRetryArgs(args map[string]any) map[string]any {
|
||||
retryArgs := cloneArgs(args)
|
||||
if retryArgs == nil {
|
||||
retryArgs = map[string]any{}
|
||||
}
|
||||
retryArgs["sandbox_permissions"] = string(tools.SandboxPermissionsRequireEscalated)
|
||||
return retryArgs
|
||||
}
|
||||
|
||||
func runToolForUnsandboxedRetry(ctx context.Context, registry *tools.Registry, name string, toolCallID string, args map[string]any, permissionMode PermissionMode, options Options, progressCallback func(streamjson.Event)) tools.Result {
|
||||
return registry.RunWithOptions(ctx, name, args, tools.RunOptions{
|
||||
PermissionGranted: true,
|
||||
PermissionMode: string(permissionMode),
|
||||
Autonomy: options.Autonomy,
|
||||
Sandbox: options.Sandbox,
|
||||
ToolCallID: toolCallID,
|
||||
SessionID: options.SessionID,
|
||||
Model: options.Model,
|
||||
ReasoningEffort: options.ReasoningEffort,
|
||||
Depth: options.Depth,
|
||||
Cwd: options.Cwd,
|
||||
FileTracker: options.FileTracker,
|
||||
EnabledTools: options.EnabledTools,
|
||||
DisabledTools: options.DisabledTools,
|
||||
Progress: progressCallback,
|
||||
})
|
||||
}
|
||||
|
||||
func toolResultFromPrePermissionReject(call ToolCall, result tools.Result) ToolResult {
|
||||
output, outputRedacted := scrubInterceptedOutput(result.Output)
|
||||
display := result.Display
|
||||
@@ -1503,12 +1142,9 @@ func toAgentAskUserQuestions(questions []tools.AskUserQuestion) []AskUserQuestio
|
||||
converted := make([]AskUserQuestion, len(questions))
|
||||
for index, question := range questions {
|
||||
converted[index] = AskUserQuestion{
|
||||
Question: question.Question,
|
||||
Header: question.Header,
|
||||
Options: append([]string{}, question.Options...),
|
||||
OptionDescriptions: append([]string{}, question.OptionDescriptions...),
|
||||
Recommended: question.Recommended,
|
||||
MultiSelect: question.MultiSelect,
|
||||
Question: question.Question,
|
||||
Options: append([]string{}, question.Options...),
|
||||
MultiSelect: question.MultiSelect,
|
||||
}
|
||||
}
|
||||
return converted
|
||||
@@ -1539,16 +1175,13 @@ func effectivePermission(tool tools.Tool, args map[string]any) tools.Permission
|
||||
return tool.Safety().Permission
|
||||
}
|
||||
|
||||
func shouldRequestPermission(tool tools.Tool, args map[string]any, permissionGranted bool, decision *sandbox.Decision) bool {
|
||||
func shouldRequestPermission(tool tools.Tool, permissionGranted bool, decision *sandbox.Decision) bool {
|
||||
if decision != nil && decision.Action == sandbox.ActionPrompt {
|
||||
return true
|
||||
}
|
||||
if tool.Safety().Permission != tools.PermissionPrompt {
|
||||
return false
|
||||
}
|
||||
if !permissionGranted && shellCommandAdditionalPermissionsRequested(args) {
|
||||
return true
|
||||
}
|
||||
if decision != nil {
|
||||
if sandboxDecisionRequiresExplicitPermission(decision) {
|
||||
return true
|
||||
@@ -2033,12 +1666,6 @@ func buildPermissionEvent(call ToolCall, tool tools.Tool, args map[string]any, p
|
||||
return PermissionEvent{}, false
|
||||
}
|
||||
|
||||
scopeText := permissionScope(call.Name, args)
|
||||
if profile, ok, err := inlineAdditionalPermissionsProfile(args, options.Cwd); ok && err == nil {
|
||||
scopeText = requestPermissionsScope(profile)
|
||||
}
|
||||
reason = userFacingPermissionReason(call.Name, args, reason)
|
||||
|
||||
return PermissionEvent{
|
||||
ToolCallID: call.ID,
|
||||
ToolName: call.Name,
|
||||
@@ -2050,7 +1677,7 @@ func buildPermissionEvent(call ToolCall, tool tools.Tool, args map[string]any, p
|
||||
Autonomy: options.Autonomy,
|
||||
SideEffect: string(safety.SideEffect),
|
||||
Reason: reason,
|
||||
Scope: scopeText,
|
||||
Scope: permissionScope(call.Name, args),
|
||||
Risk: risk,
|
||||
Block: block,
|
||||
GrantMatched: grantMatched,
|
||||
@@ -2059,20 +1686,6 @@ func buildPermissionEvent(call ToolCall, tool tools.Tool, args map[string]any, p
|
||||
}, true
|
||||
}
|
||||
|
||||
func userFacingPermissionReason(toolName string, args map[string]any, reason string) string {
|
||||
reason = strings.TrimSpace(reason)
|
||||
if !isShellCommandTool(toolName) || !shellCommandRequiresEscalated(args) {
|
||||
return reason
|
||||
}
|
||||
if justification, ok := firstStringArg(args, "justification"); ok {
|
||||
return justification
|
||||
}
|
||||
if reason == "" || reason == sandbox.ReasonEscalatedSandboxRequired {
|
||||
return "This command needs to run outside the sandbox."
|
||||
}
|
||||
return reason
|
||||
}
|
||||
|
||||
func grantDecisionAction(grant *sandbox.Grant) PermissionDecisionAction {
|
||||
if grant == nil {
|
||||
return ""
|
||||
@@ -2108,25 +1721,24 @@ func permissionRequestFromEvent(event PermissionEvent, args map[string]any, opti
|
||||
GrantMatched: event.GrantMatched,
|
||||
Grant: event.Grant,
|
||||
CommandPrefix: append([]string(nil), event.CommandPrefix...),
|
||||
AvailableDecisions: availablePermissionDecisions(event, args, options),
|
||||
AvailableDecisions: availablePermissionDecisions(event, options),
|
||||
}
|
||||
}
|
||||
|
||||
func availablePermissionDecisions(event PermissionEvent, args map[string]any, options Options) []PermissionDecisionAction {
|
||||
func availablePermissionDecisions(event PermissionEvent, options Options) []PermissionDecisionAction {
|
||||
if event.Action != PermissionActionPrompt {
|
||||
return nil
|
||||
}
|
||||
decisions := []PermissionDecisionAction{PermissionDecisionAllow}
|
||||
inlineAdditionalPermissions := shellCommandAdditionalPermissionsRequested(args)
|
||||
if options.Sandbox != nil {
|
||||
decisions = append(decisions, PermissionDecisionAllowForSession)
|
||||
if isShellCommandTool(event.ToolName) && len(event.CommandPrefix) > 0 && !networkSandboxPrompt(event) && !inlineAdditionalPermissions {
|
||||
if isShellCommandTool(event.ToolName) && len(event.CommandPrefix) > 0 && !networkSandboxPrompt(event) {
|
||||
decisions = append(decisions, PermissionDecisionAllowPrefix)
|
||||
if options.Sandbox.CanPersistGrants() {
|
||||
decisions = append(decisions, PermissionDecisionAlwaysAllowPrefix)
|
||||
}
|
||||
}
|
||||
if options.Sandbox.CanPersistGrants() && permissionSupportsPersistentDecision(event.ToolName) && !filesystemSandboxPrompt(event) && !inlineAdditionalPermissions {
|
||||
if options.Sandbox.CanPersistGrants() && permissionSupportsPersistentDecision(event.ToolName) && !filesystemSandboxPrompt(event) {
|
||||
decisions = append(decisions, PermissionDecisionAlwaysAllow)
|
||||
}
|
||||
}
|
||||
@@ -2147,74 +1759,6 @@ func networkSandboxPrompt(event PermissionEvent) bool {
|
||||
sandbox.HasRiskCategory(event.Risk, "network")
|
||||
}
|
||||
|
||||
func ensureRiskCategory(risk sandbox.Risk, category string, level sandbox.RiskLevel) sandbox.Risk {
|
||||
if !sandbox.HasRiskCategory(risk, category) {
|
||||
risk.Categories = append(risk.Categories, category)
|
||||
sort.Strings(risk.Categories)
|
||||
}
|
||||
risk.Level = level
|
||||
if len(risk.Categories) > 0 {
|
||||
risk.Reason = fmt.Sprintf("%s risk: %s", risk.Level, strings.Join(risk.Categories, ", "))
|
||||
}
|
||||
return risk
|
||||
}
|
||||
|
||||
func shellCommandAdditionalPermissionsRequested(args map[string]any) bool {
|
||||
raw, ok := args["sandbox_permissions"]
|
||||
if !ok || raw == nil {
|
||||
return false
|
||||
}
|
||||
value, ok := raw.(string)
|
||||
if !ok {
|
||||
value = fmt.Sprint(raw)
|
||||
}
|
||||
return strings.TrimSpace(value) == string(tools.SandboxPermissionsWithAdditionalPermissions)
|
||||
}
|
||||
|
||||
func inlineAdditionalPermissionsProfile(args map[string]any, basePath string) (sandbox.RequestPermissionProfile, bool, error) {
|
||||
if !shellCommandAdditionalPermissionsRequested(args) {
|
||||
if _, exists := args["additional_permissions"]; exists {
|
||||
return sandbox.RequestPermissionProfile{}, false, fmt.Errorf("additional_permissions requires sandbox_permissions set to %q", tools.SandboxPermissionsWithAdditionalPermissions)
|
||||
}
|
||||
return sandbox.RequestPermissionProfile{}, false, nil
|
||||
}
|
||||
raw, ok := args["additional_permissions"]
|
||||
if !ok || raw == nil {
|
||||
return sandbox.RequestPermissionProfile{}, true, fmt.Errorf("missing additional_permissions; provide at least one of network or file_system")
|
||||
}
|
||||
data, err := json.Marshal(raw)
|
||||
if err != nil {
|
||||
return sandbox.RequestPermissionProfile{}, true, err
|
||||
}
|
||||
var profile sandbox.RequestPermissionProfile
|
||||
if err := json.Unmarshal(data, &profile); err != nil {
|
||||
return sandbox.RequestPermissionProfile{}, true, err
|
||||
}
|
||||
normalized, err := sandbox.NormalizeRequestPermissionProfile(profile, basePath)
|
||||
if err != nil {
|
||||
return sandbox.RequestPermissionProfile{}, true, err
|
||||
}
|
||||
if normalized.Empty() {
|
||||
return sandbox.RequestPermissionProfile{}, true, fmt.Errorf("additional_permissions must include at least one requested permission in network or file_system")
|
||||
}
|
||||
grantProfile, err := sandbox.RequestPermissionGrantProfile(normalized)
|
||||
if err != nil {
|
||||
return sandbox.RequestPermissionProfile{}, true, err
|
||||
}
|
||||
return grantProfile, true, nil
|
||||
}
|
||||
|
||||
func grantInlineAdditionalPermissions(args map[string]any, scope sandbox.PermissionGrantScope, options Options) (func(), error) {
|
||||
profile, ok, err := inlineAdditionalPermissionsProfile(args, options.Cwd)
|
||||
if err != nil || !ok {
|
||||
return nil, err
|
||||
}
|
||||
if options.Sandbox == nil {
|
||||
return nil, errors.New("sandbox engine is not configured")
|
||||
}
|
||||
return options.Sandbox.GrantRequestPermissions(profile, scope)
|
||||
}
|
||||
|
||||
func grantNetworkForSandboxPrompt(event PermissionEvent, scope sandbox.PermissionGrantScope, options Options) (func(), error) {
|
||||
if !networkSandboxPrompt(event) || options.Sandbox == nil {
|
||||
return nil, nil
|
||||
@@ -2255,8 +1799,6 @@ func permissionSupportsPersistentDecision(toolName string) bool {
|
||||
switch toolName {
|
||||
case "bash", "exec_command", "write_stdin", "apply_patch":
|
||||
return false
|
||||
case "browser_install", "browser_launch", "browser_connect", "browser_open", "browser_snapshot", "browser_click", "browser_type", "browser_press", "browser_action", "desktop_windows", "desktop_snapshot", "desktop_action", "terminal_session", "capture_artifact":
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
@@ -2284,15 +1826,15 @@ func permissionActionFromSandbox(action sandbox.Action) PermissionAction {
|
||||
}
|
||||
}
|
||||
|
||||
// partitionTools builds the per-turn advertised tool list and optional
|
||||
// tool_search discovery text. INACTIVE (DeferThreshold <= 0 or the eligible count is
|
||||
// partitionTools builds the per-turn advertised tool list and an optional
|
||||
// deferred-tools reminder. INACTIVE (DeferThreshold <= 0 or the eligible count is
|
||||
// below it): every visible tool is exposed with its full schema EXCEPT tool_search
|
||||
// (dropped so it is never advertised when it cannot help), and the discovery text is
|
||||
// (dropped so it is never advertised when it cannot help), and the reminder is
|
||||
// empty — byte-identical to the pre-deferral output. ACTIVE: a deferred-eligible
|
||||
// tool is exposed only when loaded[name]; otherwise it is hidden and searchable
|
||||
// through tool_search. Non-deferred tools (including tool_search) are always
|
||||
// exposed. The exposed slice is alpha-sorted by name, matching the legacy order
|
||||
// so the inactive path is stable.
|
||||
// tool is exposed only when loaded[name]; otherwise it is hidden and its compact
|
||||
// line goes into the reminder. Non-deferred tools (including tool_search) are
|
||||
// always exposed. The exposed slice is alpha-sorted by name, matching the legacy
|
||||
// order so the inactive path is stable.
|
||||
func partitionTools(registry *tools.Registry, permissionMode PermissionMode, options Options, loaded map[string]bool) ([]zeroruntime.ToolDefinition, string) {
|
||||
registeredTools := registry.All()
|
||||
|
||||
@@ -2303,12 +1845,7 @@ func partitionTools(registry *tools.Registry, permissionMode PermissionMode, opt
|
||||
continue
|
||||
}
|
||||
visible = append(visible, tool)
|
||||
// Count by deferral-ELIGIBILITY, not current deferred state: a tool that
|
||||
// un-defers at runtime (e.g. swarm coordination tools once a swarm is
|
||||
// active) still counts, so it can't drop `eligible` below the threshold
|
||||
// and force-expose every other deferred tool. Whether a tool is actually
|
||||
// hidden is decided by IsDeferred in the exposure loop below.
|
||||
if tools.IsDeferralEligible(tool) {
|
||||
if tools.IsDeferred(tool) {
|
||||
eligible++
|
||||
}
|
||||
}
|
||||
@@ -2330,7 +1867,7 @@ func partitionTools(registry *tools.Registry, permissionMode PermissionMode, opt
|
||||
|
||||
definitions := make([]zeroruntime.ToolDefinition, 0, len(visible))
|
||||
exposedNames := make(map[string]bool, len(visible))
|
||||
var hiddenTools []tools.Tool
|
||||
var hiddenLines []string
|
||||
for _, tool := range visible {
|
||||
name := tool.Name()
|
||||
deferred := tools.IsDeferred(tool)
|
||||
@@ -2351,7 +1888,7 @@ func partitionTools(registry *tools.Registry, permissionMode PermissionMode, opt
|
||||
|
||||
// Active path.
|
||||
if deferred && !loaded[name] {
|
||||
hiddenTools = append(hiddenTools, tool)
|
||||
hiddenLines = append(hiddenLines, tools.DeferredLine(tool))
|
||||
continue
|
||||
}
|
||||
definitions = append(definitions, zeroruntime.ToolDefinition{
|
||||
@@ -2368,34 +1905,23 @@ func partitionTools(registry *tools.Registry, permissionMode PermissionMode, opt
|
||||
// tools, not the loader). Expose its full definition whenever it is not
|
||||
// already in the exposed set. This never runs on the inactive path, so the
|
||||
// byte-identical below-threshold output is preserved.
|
||||
discovery := ""
|
||||
if active && len(hiddenTools) > 0 {
|
||||
discovery = tools.BuildToolSearchDescription(hiddenTools)
|
||||
}
|
||||
if active && !exposedNames[tools.ToolSearchToolName] {
|
||||
description := loader.Description()
|
||||
if discovery != "" {
|
||||
description = discovery
|
||||
}
|
||||
definitions = append(definitions, zeroruntime.ToolDefinition{
|
||||
Name: loader.Name(),
|
||||
Description: description,
|
||||
Description: loader.Description(),
|
||||
Parameters: schemaToRuntimeMap(loader.Parameters()),
|
||||
})
|
||||
} else if active && discovery != "" {
|
||||
for i := range definitions {
|
||||
if definitions[i].Name == tools.ToolSearchToolName {
|
||||
definitions[i].Description = discovery
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sort.Slice(definitions, func(left int, right int) bool {
|
||||
return definitions[left].Name < definitions[right].Name
|
||||
})
|
||||
|
||||
return definitions, discovery
|
||||
reminder := ""
|
||||
if active {
|
||||
reminder = tools.BuildDeferredReminder(hiddenLines)
|
||||
}
|
||||
return definitions, reminder
|
||||
}
|
||||
|
||||
func ToolVisible(tool tools.Tool, permissionMode PermissionMode, enabledTools []string, disabledTools []string) bool {
|
||||
@@ -2489,21 +2015,6 @@ func ToolAdvertised(tool tools.Tool, permissionMode PermissionMode) bool {
|
||||
if permissionMode == PermissionModeAuto {
|
||||
return tool.Safety().Permission == tools.PermissionAllow || tool.Safety().AdvertiseInAuto
|
||||
}
|
||||
if permissionMode == PermissionModeMemberAuto {
|
||||
// Like Auto, plus the in-workspace mutators a headless member needs to
|
||||
// build. The sandbox engine still decides at call time: in-workspace writes
|
||||
// and sandbox-backed shell auto-allow, while out-of-workspace writes,
|
||||
// network, and destructive commands prompt → denied headless. So this
|
||||
// advertises capability without widening sandbox authority.
|
||||
if tool.Safety().Permission == tools.PermissionAllow || tool.Safety().AdvertiseInAuto {
|
||||
return true
|
||||
}
|
||||
switch tool.Safety().SideEffect {
|
||||
case tools.SideEffectWrite, tools.SideEffectShell:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
+9
-630
@@ -37,365 +37,6 @@ func (provider *mockProvider) StreamCompletion(ctx context.Context, request zero
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
type sandboxDeniedRetryTool struct {
|
||||
calls []map[string]any
|
||||
}
|
||||
|
||||
func (tool *sandboxDeniedRetryTool) Name() string { return "bash" }
|
||||
func (tool *sandboxDeniedRetryTool) Description() string { return "test shell tool" }
|
||||
func (tool *sandboxDeniedRetryTool) Parameters() tools.Schema {
|
||||
return tools.Schema{
|
||||
Type: "object",
|
||||
Properties: map[string]tools.PropertySchema{
|
||||
"command": {Type: "string"},
|
||||
"sandbox_permissions": {Type: "string"},
|
||||
"justification": {Type: "string"},
|
||||
},
|
||||
Required: []string{"command"},
|
||||
AdditionalProperties: false,
|
||||
}
|
||||
}
|
||||
func (tool *sandboxDeniedRetryTool) Safety() tools.Safety {
|
||||
return tools.Safety{SideEffect: tools.SideEffectShell, Permission: tools.PermissionPrompt, Reason: "runs shell commands"}
|
||||
}
|
||||
func (tool *sandboxDeniedRetryTool) Run(_ context.Context, args map[string]any) tools.Result {
|
||||
tool.calls = append(tool.calls, cloneArgs(args))
|
||||
if args["sandbox_permissions"] == string(tools.SandboxPermissionsRequireEscalated) {
|
||||
return tools.Result{Status: tools.StatusOK, Output: "installed"}
|
||||
}
|
||||
return tools.Result{
|
||||
Status: tools.StatusError,
|
||||
Output: "touch: cannot touch '/home/user/.npm/cache': Read-only file system",
|
||||
Meta: map[string]string{
|
||||
"exit_code": "1",
|
||||
tools.SandboxLikelyDeniedMeta: "true",
|
||||
tools.SandboxDenialReasonMeta: "sandbox blocked command execution",
|
||||
tools.SandboxDenialKeywordMeta: "read-only file system",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type sandboxDeniedExecCommandRetryTool struct {
|
||||
sandboxDeniedRetryTool
|
||||
}
|
||||
|
||||
func (tool *sandboxDeniedExecCommandRetryTool) Name() string { return "exec_command" }
|
||||
|
||||
type sandboxNetworkDeniedRetryTool struct {
|
||||
calls []map[string]any
|
||||
}
|
||||
|
||||
func (tool *sandboxNetworkDeniedRetryTool) Name() string { return "bash" }
|
||||
func (tool *sandboxNetworkDeniedRetryTool) Description() string { return "test shell tool" }
|
||||
func (tool *sandboxNetworkDeniedRetryTool) Parameters() tools.Schema {
|
||||
return tools.Schema{
|
||||
Type: "object",
|
||||
Properties: map[string]tools.PropertySchema{
|
||||
"command": {Type: "string"},
|
||||
},
|
||||
Required: []string{"command"},
|
||||
AdditionalProperties: false,
|
||||
}
|
||||
}
|
||||
func (tool *sandboxNetworkDeniedRetryTool) Safety() tools.Safety {
|
||||
return tools.Safety{SideEffect: tools.SideEffectShell, Permission: tools.PermissionPrompt, Reason: "runs shell commands"}
|
||||
}
|
||||
func (tool *sandboxNetworkDeniedRetryTool) Run(ctx context.Context, args map[string]any) tools.Result {
|
||||
return tool.RunWithOptions(ctx, args, tools.RunOptions{})
|
||||
}
|
||||
func (tool *sandboxNetworkDeniedRetryTool) RunWithOptions(ctx context.Context, args map[string]any, options tools.RunOptions) tools.Result {
|
||||
tool.calls = append(tool.calls, cloneArgs(args))
|
||||
if shellNetworkAllowed(ctx, options.Sandbox, args) {
|
||||
return tools.Result{Status: tools.StatusOK, Output: "server started"}
|
||||
}
|
||||
return tools.Result{
|
||||
Status: tools.StatusOK,
|
||||
Output: "Cannot open a network socket.",
|
||||
Meta: map[string]string{
|
||||
"exit_code": "0",
|
||||
tools.SandboxLikelyDeniedMeta: "true",
|
||||
tools.SandboxDenialKindMeta: tools.SandboxDenialKindNetwork,
|
||||
tools.SandboxDenialReasonMeta: "sandbox blocked command execution",
|
||||
tools.SandboxDenialKeywordMeta: "cannot open a network socket",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func shellNetworkAllowed(ctx context.Context, engine *sandbox.Engine, args map[string]any) bool {
|
||||
if engine == nil {
|
||||
return false
|
||||
}
|
||||
decision := engine.Evaluate(ctx, sandbox.Request{
|
||||
ToolName: "bash",
|
||||
SideEffect: sandbox.SideEffectShell,
|
||||
Permission: sandbox.PermissionPrompt,
|
||||
PermissionGranted: true,
|
||||
PermissionMode: sandbox.PermissionModeAsk,
|
||||
Args: map[string]any{"command": "curl https://example.com"},
|
||||
})
|
||||
return decision.Action == sandbox.ActionAllow
|
||||
}
|
||||
|
||||
func agentNativeBackendStub() sandbox.Backend {
|
||||
return sandbox.Backend{
|
||||
Name: sandbox.BackendLinuxBwrap,
|
||||
Available: true,
|
||||
Executable: "/nonexistent/zero-linux-sandbox-stub",
|
||||
CommandWrapping: true,
|
||||
NativeIsolation: true,
|
||||
}
|
||||
}
|
||||
|
||||
type sandboxNamespaceLimitedRetryTool struct {
|
||||
calls []map[string]any
|
||||
}
|
||||
|
||||
func (tool *sandboxNamespaceLimitedRetryTool) Name() string { return "bash" }
|
||||
func (tool *sandboxNamespaceLimitedRetryTool) Description() string { return "test shell tool" }
|
||||
func (tool *sandboxNamespaceLimitedRetryTool) Parameters() tools.Schema {
|
||||
return tools.Schema{
|
||||
Type: "object",
|
||||
Properties: map[string]tools.PropertySchema{
|
||||
"command": {Type: "string"},
|
||||
"sandbox_permissions": {Type: "string"},
|
||||
},
|
||||
Required: []string{"command"},
|
||||
AdditionalProperties: false,
|
||||
}
|
||||
}
|
||||
func (tool *sandboxNamespaceLimitedRetryTool) Safety() tools.Safety {
|
||||
return tools.Safety{SideEffect: tools.SideEffectShell, Permission: tools.PermissionPrompt, Reason: "runs shell commands"}
|
||||
}
|
||||
func (tool *sandboxNamespaceLimitedRetryTool) Run(_ context.Context, args map[string]any) tools.Result {
|
||||
tool.calls = append(tool.calls, cloneArgs(args))
|
||||
if args["sandbox_permissions"] == string(tools.SandboxPermissionsRequireEscalated) {
|
||||
return tools.Result{Status: tools.StatusOK, Output: "stdout:\nUSER PID COMMAND\nanaxy 42 firefox\nanaxy 43 Discord"}
|
||||
}
|
||||
return tools.Result{
|
||||
Status: tools.StatusOK,
|
||||
Output: "stdout:\nUSER PID COMMAND\nanaxy 1 bwrap --new-session --die-with-parent --unshare-user --unshare-pid --unshare-net --proc /proc -- /bin/sh -c ps aux\nanaxy 2 ps aux",
|
||||
Meta: map[string]string{
|
||||
"sandbox_backend": string(sandbox.BackendLinuxBwrap),
|
||||
"sandbox_target_backend": string(sandbox.BackendLinuxBwrap),
|
||||
"sandbox_wrapped": "true",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunRetriesShellUnsandboxedAfterSandboxNamespaceLimitedOutput(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
retryTool := &sandboxNamespaceLimitedRetryTool{}
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(retryTool)
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "bash"},
|
||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{"command":"ps aux"}`},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
{
|
||||
{Type: zeroruntime.StreamEventText, Content: "done"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
},
|
||||
}
|
||||
var requests []PermissionRequest
|
||||
var events []PermissionEvent
|
||||
|
||||
result, err := Run(context.Background(), "check running applications", provider, Options{
|
||||
Registry: registry,
|
||||
PermissionMode: PermissionModeAsk,
|
||||
Autonomy: "medium",
|
||||
Sandbox: sandbox.NewEngine(sandbox.EngineOptions{
|
||||
WorkspaceRoot: root,
|
||||
Policy: sandbox.DefaultPolicy(),
|
||||
Backend: agentNativeBackendStub(),
|
||||
}),
|
||||
OnPermissionRequest: func(_ context.Context, request PermissionRequest) (PermissionDecision, error) {
|
||||
requests = append(requests, request)
|
||||
return PermissionDecision{Action: PermissionDecisionAllowPrefix, Reason: "inspect host processes"}, nil
|
||||
},
|
||||
OnPermission: func(event PermissionEvent) {
|
||||
events = append(events, event)
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.FinalAnswer != "done" {
|
||||
t.Fatalf("final answer = %q", result.FinalAnswer)
|
||||
}
|
||||
if len(retryTool.calls) != 2 {
|
||||
t.Fatalf("tool calls = %#v, want sandboxed attempt plus unsandboxed retry", retryTool.calls)
|
||||
}
|
||||
if retryTool.calls[1]["sandbox_permissions"] != string(tools.SandboxPermissionsRequireEscalated) {
|
||||
t.Fatalf("retry args = %#v, want require_escalated", retryTool.calls[1])
|
||||
}
|
||||
if len(requests) != 1 {
|
||||
t.Fatalf("permission requests = %#v, want one unsandboxed retry approval", requests)
|
||||
}
|
||||
if !strings.Contains(requests[0].Reason, "sandbox PID namespace") {
|
||||
t.Fatalf("permission reason = %q, want sandbox namespace reason", requests[0].Reason)
|
||||
}
|
||||
if !equalStringSlices(requests[0].CommandPrefix, []string{"ps", "aux"}) {
|
||||
t.Fatalf("request command prefix = %#v, want ps aux", requests[0].CommandPrefix)
|
||||
}
|
||||
if len(events) != 1 || events[0].Action != PermissionActionAllow || events[0].DecisionAction != PermissionDecisionAllowPrefix {
|
||||
t.Fatalf("permission events = %#v, want approved unsandboxed retry", events)
|
||||
}
|
||||
if len(provider.requests) < 2 {
|
||||
t.Fatalf("provider requests = %d, want retry result sent back to model", len(provider.requests))
|
||||
}
|
||||
last := provider.requests[1].Messages[len(provider.requests[1].Messages)-1]
|
||||
if !strings.Contains(last.Content, "firefox") || strings.Contains(last.Content, "bwrap --new-session") {
|
||||
t.Fatalf("tool result sent to model = %q, want unsandboxed retry output only", last.Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunUsesEscalatedJustificationForPermissionPrompt(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(&sandboxDeniedRetryTool{})
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "bash"},
|
||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{"command":"ps aux","sandbox_permissions":"require_escalated","justification":"Need host process visibility."}`},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
},
|
||||
}
|
||||
var requestReasons []string
|
||||
|
||||
_, err := Run(context.Background(), "check host processes", provider, Options{
|
||||
Registry: registry,
|
||||
PermissionMode: PermissionModeAsk,
|
||||
Sandbox: sandbox.NewEngine(sandbox.EngineOptions{
|
||||
WorkspaceRoot: root,
|
||||
Policy: sandbox.DefaultPolicy(),
|
||||
Backend: agentNativeBackendStub(),
|
||||
}),
|
||||
OnPermissionRequest: func(_ context.Context, request PermissionRequest) (PermissionDecision, error) {
|
||||
requestReasons = append(requestReasons, request.Reason)
|
||||
return PermissionDecision{Action: PermissionDecisionDeny, Reason: "not needed"}, nil
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(requestReasons) != 1 {
|
||||
t.Fatalf("permission reasons = %#v, want one request", requestReasons)
|
||||
}
|
||||
if requestReasons[0] != "Need host process visibility." {
|
||||
t.Fatalf("permission reason = %q, want model justification", requestReasons[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunUsesUserFacingEscalatedFallbackForPermissionPrompt(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(&sandboxDeniedExecCommandRetryTool{})
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "exec_command"},
|
||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{"command":"ps aux","sandbox_permissions":"require_escalated"}`},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
},
|
||||
}
|
||||
var requestReasons []string
|
||||
|
||||
_, err := Run(context.Background(), "check host processes", provider, Options{
|
||||
Registry: registry,
|
||||
PermissionMode: PermissionModeAsk,
|
||||
Sandbox: sandbox.NewEngine(sandbox.EngineOptions{
|
||||
WorkspaceRoot: root,
|
||||
Policy: sandbox.DefaultPolicy(),
|
||||
Backend: agentNativeBackendStub(),
|
||||
}),
|
||||
OnPermissionRequest: func(_ context.Context, request PermissionRequest) (PermissionDecision, error) {
|
||||
requestReasons = append(requestReasons, request.Reason)
|
||||
return PermissionDecision{Action: PermissionDecisionDeny, Reason: "not needed"}, nil
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(requestReasons) != 1 {
|
||||
t.Fatalf("permission reasons = %#v, want one request", requestReasons)
|
||||
}
|
||||
if requestReasons[0] != "This command needs to run outside the sandbox." {
|
||||
t.Fatalf("permission reason = %q, want user-facing fallback", requestReasons[0])
|
||||
}
|
||||
if strings.Contains(requestReasons[0], sandbox.ReasonEscalatedSandboxRequired) {
|
||||
t.Fatalf("permission reason leaked internal sandbox copy: %q", requestReasons[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunRetriesNetworkDeniedShellWithNetworkGrant(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
retryTool := &sandboxNetworkDeniedRetryTool{}
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(retryTool)
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "bash"},
|
||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{"command":"node server.js"}`},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
{
|
||||
{Type: zeroruntime.StreamEventText, Content: "done"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
},
|
||||
}
|
||||
var requests []PermissionRequest
|
||||
|
||||
result, err := Run(context.Background(), "start server", provider, Options{
|
||||
Registry: registry,
|
||||
PermissionMode: PermissionModeAsk,
|
||||
Autonomy: "medium",
|
||||
Sandbox: sandbox.NewEngine(sandbox.EngineOptions{
|
||||
WorkspaceRoot: root,
|
||||
Policy: sandbox.DefaultPolicy(),
|
||||
Backend: agentNativeBackendStub(),
|
||||
}),
|
||||
OnPermissionRequest: func(_ context.Context, request PermissionRequest) (PermissionDecision, error) {
|
||||
requests = append(requests, request)
|
||||
return PermissionDecision{Action: PermissionDecisionAllow, Reason: "approve network"}, nil
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.FinalAnswer != "done" {
|
||||
t.Fatalf("final answer = %q", result.FinalAnswer)
|
||||
}
|
||||
if len(retryTool.calls) != 2 {
|
||||
t.Fatalf("tool calls = %#v, want denied attempt plus network-granted retry", retryTool.calls)
|
||||
}
|
||||
if _, escalated := retryTool.calls[1]["sandbox_permissions"]; escalated {
|
||||
t.Fatalf("network retry must stay sandboxed, got retry args %#v", retryTool.calls[1])
|
||||
}
|
||||
if len(requests) != 1 || requests[0].Reason != sandbox.ReasonNetworkBlocked {
|
||||
t.Fatalf("permission requests = %#v, want one network approval", requests)
|
||||
}
|
||||
if len(provider.requests) < 2 {
|
||||
t.Fatalf("provider requests = %d, want retry result sent back to model", len(provider.requests))
|
||||
}
|
||||
last := provider.requests[1].Messages[len(provider.requests[1].Messages)-1]
|
||||
if !strings.Contains(last.Content, "server started") {
|
||||
t.Fatalf("tool result sent to model = %q, want network retry output", last.Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsRetriableToolError(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
@@ -423,145 +64,6 @@ func TestIsRetriableToolError(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunRetriesShellUnsandboxedAfterSandboxDeniedExit(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
retryTool := &sandboxDeniedRetryTool{}
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(retryTool)
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "bash"},
|
||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{"command":"npm install --save http-server"}`},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
{
|
||||
{Type: zeroruntime.StreamEventText, Content: "done"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
},
|
||||
}
|
||||
var requests []PermissionRequest
|
||||
var events []PermissionEvent
|
||||
|
||||
result, err := Run(context.Background(), "install package", provider, Options{
|
||||
Registry: registry,
|
||||
PermissionMode: PermissionModeAsk,
|
||||
Autonomy: "medium",
|
||||
Sandbox: sandbox.NewEngine(sandbox.EngineOptions{
|
||||
WorkspaceRoot: root,
|
||||
Policy: sandbox.DefaultPolicy(),
|
||||
Backend: agentNativeBackendStub(),
|
||||
}),
|
||||
OnPermissionRequest: func(_ context.Context, request PermissionRequest) (PermissionDecision, error) {
|
||||
requests = append(requests, request)
|
||||
return PermissionDecision{Action: PermissionDecisionAllow, Reason: "retry outside sandbox"}, nil
|
||||
},
|
||||
OnPermission: func(event PermissionEvent) {
|
||||
events = append(events, event)
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.FinalAnswer != "done" {
|
||||
t.Fatalf("final answer = %q", result.FinalAnswer)
|
||||
}
|
||||
if len(retryTool.calls) != 2 {
|
||||
t.Fatalf("tool calls = %#v, want sandboxed attempt plus unsandboxed retry", retryTool.calls)
|
||||
}
|
||||
if retryTool.calls[1]["sandbox_permissions"] != string(tools.SandboxPermissionsRequireEscalated) {
|
||||
t.Fatalf("retry args = %#v, want require_escalated", retryTool.calls[1])
|
||||
}
|
||||
if len(requests) != 2 {
|
||||
t.Fatalf("permission requests = %#v, want network approval plus retry approval", requests)
|
||||
}
|
||||
if requests[0].Reason != sandbox.ReasonNetworkBlocked {
|
||||
t.Fatalf("first request reason = %q, want network approval", requests[0].Reason)
|
||||
}
|
||||
if !strings.Contains(requests[1].Reason, "sandbox blocked command execution") || !strings.Contains(requests[1].Reason, "read-only file system") {
|
||||
t.Fatalf("retry request reason = %q", requests[1].Reason)
|
||||
}
|
||||
if len(events) != 1 || events[0].Action != PermissionActionAllow || events[0].DecisionAction != PermissionDecisionAllow {
|
||||
t.Fatalf("permission events = %#v, want approved unsandboxed retry", events)
|
||||
}
|
||||
if len(provider.requests) < 2 {
|
||||
t.Fatalf("provider requests = %d, want retry result sent back to model", len(provider.requests))
|
||||
}
|
||||
last := provider.requests[1].Messages[len(provider.requests[1].Messages)-1]
|
||||
if !strings.Contains(last.Content, "installed") {
|
||||
t.Fatalf("tool result sent to model = %q, want retry output", last.Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunDoesNotRetryUnsandboxedWhenDeniedReadsActive(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
retryTool := &sandboxDeniedRetryTool{}
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(retryTool)
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "bash"},
|
||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{"command":"touch cache-file"}`},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
{
|
||||
{Type: zeroruntime.StreamEventText, Content: "done"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
},
|
||||
}
|
||||
var requests []PermissionRequest
|
||||
var events []PermissionEvent
|
||||
policy := sandbox.DefaultPolicy()
|
||||
policy.DenyRead = []string{filepath.Join(root, "secret.txt")}
|
||||
|
||||
result, err := Run(context.Background(), "touch cache", provider, Options{
|
||||
Registry: registry,
|
||||
PermissionMode: PermissionModeAsk,
|
||||
Autonomy: "medium",
|
||||
Sandbox: sandbox.NewEngine(sandbox.EngineOptions{
|
||||
WorkspaceRoot: root,
|
||||
Policy: policy,
|
||||
Backend: agentNativeBackendStub(),
|
||||
}),
|
||||
OnPermissionRequest: func(_ context.Context, request PermissionRequest) (PermissionDecision, error) {
|
||||
requests = append(requests, request)
|
||||
return PermissionDecision{Action: PermissionDecisionAllow, Reason: "unexpected"}, nil
|
||||
},
|
||||
OnPermission: func(event PermissionEvent) {
|
||||
events = append(events, event)
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.FinalAnswer != "done" {
|
||||
t.Fatalf("final answer = %q", result.FinalAnswer)
|
||||
}
|
||||
if len(retryTool.calls) != 1 {
|
||||
t.Fatalf("tool calls = %#v, want only the original sandboxed attempt", retryTool.calls)
|
||||
}
|
||||
if len(requests) != 0 {
|
||||
t.Fatalf("permission requests = %#v, want no unsandboxed retry request", requests)
|
||||
}
|
||||
for _, event := range events {
|
||||
if strings.Contains(event.Reason, "sandbox blocked command execution") {
|
||||
t.Fatalf("permission events = %#v, want no unsandboxed retry event", events)
|
||||
}
|
||||
}
|
||||
if len(provider.requests) < 2 {
|
||||
t.Fatalf("provider requests = %d, want tool result sent back to model", len(provider.requests))
|
||||
}
|
||||
last := provider.requests[1].Messages[len(provider.requests[1].Messages)-1]
|
||||
if !strings.Contains(last.Content, "Read-only file system") {
|
||||
t.Fatalf("tool result sent to model = %q, want original sandbox denial", last.Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunReturnsProviderText(t *testing.T) {
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{{
|
||||
@@ -1523,7 +1025,7 @@ func TestAvailablePermissionDecisionsSplitDenyAndCancel(t *testing.T) {
|
||||
got := availablePermissionDecisions(PermissionEvent{
|
||||
ToolName: tc.tool,
|
||||
Action: PermissionActionPrompt,
|
||||
}, nil, options)
|
||||
}, options)
|
||||
if !equalPermissionDecisions(got, tc.want) {
|
||||
t.Fatalf("decisions = %#v, want %#v", got, tc.want)
|
||||
}
|
||||
@@ -1758,129 +1260,6 @@ func TestRunCommandPrefixApprovalSkipsLaterMatchingBashPrompt(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunCommandPrefixApprovalBypassesSandboxForMatchingShellCalls(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
retryTool := &sandboxDeniedRetryTool{}
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(retryTool)
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "bash"},
|
||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{"command":"echo prefix-ok"}`},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
{
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-2", ToolName: "bash"},
|
||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-2", ArgumentsFragment: `{"command":"echo prefix-ok again"}`},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-2"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
{
|
||||
{Type: zeroruntime.StreamEventText, Content: "done"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
},
|
||||
}
|
||||
var requests []PermissionRequest
|
||||
|
||||
result, err := Run(context.Background(), "run twice", provider, Options{
|
||||
Registry: registry,
|
||||
PermissionMode: PermissionModeAsk,
|
||||
Autonomy: "medium",
|
||||
Sandbox: sandbox.NewEngine(sandbox.EngineOptions{
|
||||
WorkspaceRoot: root,
|
||||
Policy: sandbox.DefaultPolicy(),
|
||||
Backend: sandbox.Backend{Name: sandbox.BackendUnavailable, Message: "native sandbox unavailable"},
|
||||
}),
|
||||
OnPermissionRequest: func(_ context.Context, request PermissionRequest) (PermissionDecision, error) {
|
||||
requests = append(requests, request)
|
||||
return PermissionDecision{Action: PermissionDecisionAllowPrefix, Reason: "trust this command prefix"}, nil
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.FinalAnswer != "done" {
|
||||
t.Fatalf("expected final answer, got %q", result.FinalAnswer)
|
||||
}
|
||||
if len(requests) != 1 {
|
||||
t.Fatalf("expected only first command to request prefix approval, got %#v", requests)
|
||||
}
|
||||
if len(retryTool.calls) != 2 {
|
||||
t.Fatalf("tool calls = %#v, want two first-attempt executions", retryTool.calls)
|
||||
}
|
||||
for index, call := range retryTool.calls {
|
||||
if call["sandbox_permissions"] != string(tools.SandboxPermissionsRequireEscalated) {
|
||||
t.Fatalf("call %d args = %#v, want prefix-approved require_escalated execution", index, call)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunCommandPrefixApprovalCoversSegmentedShellWithSafeTail(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
retryTool := &sandboxDeniedRetryTool{}
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(retryTool)
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "bash"},
|
||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{"command":"ps aux"}`},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
{
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-2", ToolName: "bash"},
|
||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-2", ArgumentsFragment: `{"command":"ps aux | head -5"}`},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-2"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
{
|
||||
{Type: zeroruntime.StreamEventText, Content: "done"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
},
|
||||
}
|
||||
var requests []PermissionRequest
|
||||
|
||||
result, err := Run(context.Background(), "inspect processes", provider, Options{
|
||||
Registry: registry,
|
||||
PermissionMode: PermissionModeAsk,
|
||||
Autonomy: "medium",
|
||||
Sandbox: sandbox.NewEngine(sandbox.EngineOptions{
|
||||
WorkspaceRoot: root,
|
||||
Policy: sandbox.DefaultPolicy(),
|
||||
Backend: sandbox.Backend{Name: sandbox.BackendUnavailable, Message: "native sandbox unavailable"},
|
||||
}),
|
||||
OnPermissionRequest: func(_ context.Context, request PermissionRequest) (PermissionDecision, error) {
|
||||
requests = append(requests, request)
|
||||
if !equalStringSlices(request.CommandPrefix, []string{"ps", "aux"}) {
|
||||
t.Fatalf("request command prefix = %#v, want ps aux", request.CommandPrefix)
|
||||
}
|
||||
return PermissionDecision{Action: PermissionDecisionAllowPrefix, Reason: "trust this command prefix"}, nil
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.FinalAnswer != "done" {
|
||||
t.Fatalf("expected final answer, got %q", result.FinalAnswer)
|
||||
}
|
||||
if len(requests) != 1 {
|
||||
t.Fatalf("expected only first command to request approval, got %#v", requests)
|
||||
}
|
||||
if len(retryTool.calls) != 2 {
|
||||
t.Fatalf("tool calls = %#v, want two first-attempt executions", retryTool.calls)
|
||||
}
|
||||
for index, call := range retryTool.calls {
|
||||
if call["sandbox_permissions"] != string(tools.SandboxPermissionsRequireEscalated) {
|
||||
t.Fatalf("call %d args = %#v, want require_escalated execution", index, call)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunPersistentCommandPrefixApprovalSkipsFutureSessionPrompt(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
store, err := sandbox.NewGrantStore(sandbox.StoreOptions{FilePath: filepath.Join(t.TempDir(), "sandbox-grants.json")})
|
||||
@@ -2115,8 +1494,8 @@ func TestRunApprovedNetworkBashPromptAppliesTurnNetworkGrant(t *testing.T) {
|
||||
t.Fatalf("expected tool result to be sent back to provider, got %d requests", len(provider.requests))
|
||||
}
|
||||
lastMessage := provider.requests[1].Messages[len(provider.requests[1].Messages)-1]
|
||||
if !strings.Contains(lastMessage.Content, "fake curl https://example.com") {
|
||||
t.Fatalf("expected approved network command output after degraded execution, got %q", lastMessage.Content)
|
||||
if !strings.Contains(lastMessage.Content, "native sandbox backend is unavailable") {
|
||||
t.Fatalf("expected native sandbox unavailable error after approval, got %q", lastMessage.Content)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2128,7 +1507,7 @@ func TestRunDoesNotOfferPrefixApprovalForUnsafeBashCommand(t *testing.T) {
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "bash"},
|
||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{"command":"echo hi && npm install","prefix_rule":["echo"]}`},
|
||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{"command":"echo hi && echo bye","prefix_rule":["echo"]}`},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
@@ -2224,8 +1603,8 @@ func TestRunPromptsForDestructiveShellInsteadOfSandboxDeny(t *testing.T) {
|
||||
t.Fatalf("expected tool result to be sent back to provider, got %d requests", len(provider.requests))
|
||||
}
|
||||
lastMessage := provider.requests[1].Messages[len(provider.requests[1].Messages)-1]
|
||||
if !strings.Contains(lastMessage.Content, "rm -rf /") {
|
||||
t.Fatalf("expected approved shell command output after degraded execution, got %q", lastMessage.Content)
|
||||
if !strings.Contains(lastMessage.Content, "native sandbox backend is unavailable") {
|
||||
t.Fatalf("expected native sandbox unavailable error after approval, got %q", lastMessage.Content)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2396,7 +1775,7 @@ func TestRunEmitsPermissionEventForPersistentSandboxGrant(t *testing.T) {
|
||||
|
||||
func TestRunPromptsAndAllowsOutsideWorkspaceWrite(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
outside := filepath.Join(tempDirOutsideDefaultTemp(t), "escape.txt")
|
||||
outside := filepath.Join(t.TempDir(), "escape.txt")
|
||||
engine := sandbox.NewEngine(sandbox.EngineOptions{
|
||||
WorkspaceRoot: root,
|
||||
Policy: sandbox.DefaultPolicy(),
|
||||
@@ -2458,7 +1837,7 @@ func TestRunPromptsAndAllowsOutsideWorkspaceWrite(t *testing.T) {
|
||||
|
||||
func TestRunSessionAllowsLaterOutsideWorkspaceWrite(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
outside := filepath.Join(tempDirOutsideDefaultTemp(t), "escape.txt")
|
||||
outside := filepath.Join(t.TempDir(), "escape.txt")
|
||||
engine := sandbox.NewEngine(sandbox.EngineOptions{
|
||||
WorkspaceRoot: root,
|
||||
Policy: sandbox.DefaultPolicy(),
|
||||
@@ -2528,7 +1907,7 @@ func TestRunSessionAllowsLaterOutsideWorkspaceWrite(t *testing.T) {
|
||||
|
||||
func TestRunAppliesSandboxEvenInUnsafeMode(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
outside := filepath.Join(tempDirOutsideDefaultTemp(t), "escape.txt")
|
||||
outside := filepath.Join(t.TempDir(), "escape.txt")
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewWriteFileTool(root))
|
||||
provider := providerCallingWritePathThenAnswer(outside, "sandbox handled")
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
)
|
||||
|
||||
type safetyTool struct {
|
||||
name string
|
||||
safety tools.Safety
|
||||
}
|
||||
|
||||
func (t safetyTool) Name() string { return t.name }
|
||||
func (t safetyTool) Description() string { return "test tool" }
|
||||
func (t safetyTool) Parameters() tools.Schema {
|
||||
return tools.Schema{Type: "object", AdditionalProperties: false}
|
||||
}
|
||||
func (t safetyTool) Safety() tools.Safety { return t.safety }
|
||||
func (t safetyTool) Run(context.Context, map[string]any) tools.Result {
|
||||
return tools.Result{Status: tools.StatusOK}
|
||||
}
|
||||
|
||||
// member-auto advertises the in-workspace mutators a headless member needs to
|
||||
// build (write/edit + shell) that plain Auto hides, but NOT network or denied
|
||||
// tools — the sandbox still gates the advertised ones at call time.
|
||||
func TestToolAdvertisedMemberAuto(t *testing.T) {
|
||||
write := safetyTool{name: "write_file", safety: tools.Safety{SideEffect: tools.SideEffectWrite, Permission: tools.PermissionPrompt}}
|
||||
shell := safetyTool{name: "bash", safety: tools.Safety{SideEffect: tools.SideEffectShell, Permission: tools.PermissionPrompt}}
|
||||
read := safetyTool{name: "read_file", safety: tools.Safety{SideEffect: tools.SideEffectRead, Permission: tools.PermissionAllow}}
|
||||
network := safetyTool{name: "net_tool", safety: tools.Safety{SideEffect: tools.SideEffectNetwork, Permission: tools.PermissionPrompt}}
|
||||
denied := safetyTool{name: "blocked", safety: tools.Safety{SideEffect: tools.SideEffectRead, Permission: tools.PermissionDeny}}
|
||||
|
||||
// Plain Auto hides prompt-requiring mutators (the read-only member problem).
|
||||
if ToolAdvertised(write, PermissionModeAuto) || ToolAdvertised(shell, PermissionModeAuto) {
|
||||
t.Fatal("Auto must NOT advertise write/shell prompt tools")
|
||||
}
|
||||
|
||||
for _, tool := range []tools.Tool{write, shell, read} {
|
||||
if !ToolAdvertised(tool, PermissionModeMemberAuto) {
|
||||
t.Fatalf("member-auto must advertise %q", tool.Name())
|
||||
}
|
||||
}
|
||||
if ToolAdvertised(network, PermissionModeMemberAuto) {
|
||||
t.Fatal("member-auto must NOT advertise a network prompt tool")
|
||||
}
|
||||
if ToolAdvertised(denied, PermissionModeMemberAuto) {
|
||||
t.Fatal("member-auto must NOT advertise a denied tool")
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
|
||||
func TestRequestPermissionsTurnGrantAllowsLaterToolAndCleansUp(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
outside := tempDirOutsideDefaultTemp(t)
|
||||
outside := t.TempDir()
|
||||
target := filepath.Join(outside, "vegetables.txt")
|
||||
scope, err := sandbox.NewScope(workspace, nil)
|
||||
if err != nil {
|
||||
@@ -120,71 +120,6 @@ func TestRequestPermissionsTurnGrantAllowsLaterToolAndCleansUp(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func tempDirOutsideDefaultTemp(t *testing.T) string {
|
||||
t.Helper()
|
||||
dir, err := os.MkdirTemp(".", ".zero-sandbox-outside-")
|
||||
if err != nil {
|
||||
t.Fatalf("MkdirTemp outside default temp: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.RemoveAll(dir) })
|
||||
abs, err := filepath.Abs(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("Abs(%q): %v", dir, err)
|
||||
}
|
||||
if resolved, err := filepath.EvalSymlinks(abs); err == nil {
|
||||
return resolved
|
||||
}
|
||||
return filepath.Clean(abs)
|
||||
}
|
||||
|
||||
func TestInlineAdditionalPermissionsGrantAllowsNetworkCommand(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
engine := sandbox.NewEngine(sandbox.EngineOptions{
|
||||
WorkspaceRoot: workspace,
|
||||
Policy: sandbox.DefaultPolicy(),
|
||||
})
|
||||
args := map[string]any{
|
||||
"sandbox_permissions": string(tools.SandboxPermissionsWithAdditionalPermissions),
|
||||
"additional_permissions": map[string]any{
|
||||
"network": map[string]any{"enabled": true},
|
||||
},
|
||||
}
|
||||
cleanup, err := grantInlineAdditionalPermissions(args, sandbox.PermissionGrantScopeTurn, Options{
|
||||
Sandbox: engine,
|
||||
Cwd: workspace,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("grantInlineAdditionalPermissions: %v", err)
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
decision := engine.Evaluate(context.Background(), sandbox.Request{
|
||||
WorkspaceRoot: workspace,
|
||||
ToolName: "exec_command",
|
||||
SideEffect: sandbox.SideEffectShell,
|
||||
Permission: sandbox.PermissionPrompt,
|
||||
PermissionGranted: true,
|
||||
PermissionMode: sandbox.PermissionModeAsk,
|
||||
Args: map[string]any{"cmd": "python3 -m http.server 8080 --bind 127.0.0.1"},
|
||||
})
|
||||
if decision.Action != sandbox.ActionAllow {
|
||||
t.Fatalf("inline network grant should allow shell network command, got %#v", decision)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInlineAdditionalPermissionsRequiresPromptUnderAutoAllow(t *testing.T) {
|
||||
args := map[string]any{
|
||||
"sandbox_permissions": string(tools.SandboxPermissionsWithAdditionalPermissions),
|
||||
"additional_permissions": map[string]any{
|
||||
"network": map[string]any{"enabled": true},
|
||||
},
|
||||
}
|
||||
decision := &sandbox.Decision{Action: sandbox.ActionAllow, AutoAllowed: true}
|
||||
if !shouldRequestPermission(tools.NewBashTool(t.TempDir()), args, false, decision) {
|
||||
t.Fatal("inline additional permissions must request approval even when a sandboxed shell would otherwise auto-allow")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestPermissionsDenyReturnsEmptyGrantAndContinues(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
|
||||
@@ -16,12 +16,7 @@ work.
|
||||
implementation, verification, and a clear summary.
|
||||
- Only stop to ask the user (via the ask_user tool) when you are genuinely
|
||||
blocked on a decision that is theirs to make and that you cannot resolve from
|
||||
the code, the request, or sensible defaults. When the answer is likely one of a
|
||||
small set, include 2-4 suggested `options` and mark the best as `recommended`
|
||||
(it must be one of the options) so the user can pick quickly; give each option a
|
||||
short `optionDescriptions` line when a one-word label needs context, and a short
|
||||
`header` (2-3 words) per question as its tab label when you ask several. Omit
|
||||
options for genuinely open-ended questions.
|
||||
the code, the request, or sensible defaults.
|
||||
|
||||
## Workflow: plan then act
|
||||
|
||||
@@ -31,29 +26,15 @@ work.
|
||||
tests, or config before you modify behavior. Never edit a file you have not
|
||||
read.
|
||||
2. **Plan.** For multi-step work, call update_plan with an ordered checklist and
|
||||
keep it live. The plan bar is the user's progress signal — call update_plan
|
||||
after EACH concrete unit of work (every file written, every command run), not
|
||||
just at coarse milestones: mark the finished item completed and the next one
|
||||
in_progress before you start it. A plan stuck at 0/N while files are landing is
|
||||
a bug, not economy — these calls are cheap, expected, and the update_plan cards
|
||||
are hidden from the transcript, so frequent updates cost the user nothing and
|
||||
never clutter the conversation. Keep at most one item in_progress, and never
|
||||
batch the updates to the end of the turn. Skip the plan for trivial one-step
|
||||
tasks.
|
||||
keep it current as you go. Skip the plan for trivial one-step tasks.
|
||||
3. **Implement.** Make focused changes that match the surrounding code's style,
|
||||
naming, and conventions. Prefer the smallest change that fully solves the
|
||||
problem. Avoid broad refactors, unrelated rewrites, dependency churn, and
|
||||
formatting-only edits unless the user asked for them.
|
||||
4. **Verify.** Verify after edits; see the testing gate below. This is
|
||||
mandatory.
|
||||
5. **Summarize.** For a substantial task, close with an ELABORATE, well-structured
|
||||
summary — never a single throwaway line. Use short headings and bullets: a
|
||||
one-line "what I built/changed"; **Where it lives** — the key files and how they
|
||||
fit together; **How each requirement is met** — walk through each requirement
|
||||
from the request and name the specifics that satisfy it; what validation ran and
|
||||
what was NOT verified; and a brief "Next steps" or "Want me to…?" offer. Scale
|
||||
the depth to the work — a big build earns several sections, a trivial fix earns
|
||||
a line or two.
|
||||
5. **Summarize.** Give a concise final response: what changed, why, which files
|
||||
matter, and what validation ran. State plainly what was not verified.
|
||||
|
||||
## Editing discipline
|
||||
|
||||
@@ -82,13 +63,8 @@ work.
|
||||
|
||||
## Tool use
|
||||
|
||||
- Lead a multi-step task with a one- or two-sentence plain-language preamble on
|
||||
your approach, so the user can follow what you're about to do. Then keep a brief
|
||||
running account: drop a short, single-line note before each SIGNIFICANT step
|
||||
(e.g. "Now the stylesheet.", "Let me wire up the cart and filters.") and explain
|
||||
the outcome. Use tools to act, not to narrate — don't announce every individual
|
||||
call or read; narrate only the steps a person would care about, and skip
|
||||
narration entirely for trivial one-step tasks.
|
||||
- Use tools to act, not to narrate. Do not announce each call; just do the work
|
||||
and explain the outcome.
|
||||
- Run independent, read-only lookups together when you can, rather than one at a
|
||||
time, to move faster.
|
||||
- exec_command is for commands that have no native tool (build, test, git,
|
||||
|
||||
@@ -23,7 +23,7 @@ func TestCoreSystemPromptIncludesCodingQualityRules(t *testing.T) {
|
||||
"avoid broad refactors",
|
||||
"search the web before answering",
|
||||
"do not recognize",
|
||||
"how each requirement is met",
|
||||
"final response",
|
||||
} {
|
||||
if !strings.Contains(prompt, want) {
|
||||
t.Fatalf("expected core system prompt to include %q, got:\n%s", want, buildSystemPrompt(Options{}))
|
||||
@@ -31,19 +31,6 @@ func TestCoreSystemPromptIncludesCodingQualityRules(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSystemPromptExplainsSandboxEscalationForHiddenHostState(t *testing.T) {
|
||||
prompt := strings.ToLower(buildSystemPrompt(Options{}))
|
||||
for _, want := range []string{
|
||||
"sandboxed shell command returns only sandbox-local state",
|
||||
"sandbox_permissions: \"require_escalated\"",
|
||||
"prefix_rule",
|
||||
} {
|
||||
if !strings.Contains(prompt, want) {
|
||||
t.Fatalf("expected sandbox escalation guidance %q, got:\n%s", want, buildSystemPrompt(Options{}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSystemPromptIncludesWorkspaceSeedFromCwd(t *testing.T) {
|
||||
cwd := t.TempDir()
|
||||
writeSystemPromptTestFile(t, cwd, "go.mod", "module example.test/zero\n")
|
||||
|
||||
+4
-43
@@ -24,15 +24,6 @@ const (
|
||||
PermissionModeAsk PermissionMode = "ask"
|
||||
PermissionModeUnsafe PermissionMode = "unsafe"
|
||||
PermissionModeSpecDraft PermissionMode = "spec-draft"
|
||||
// PermissionModeMemberAuto is a headless mode for swarm/specialist MEMBERS: it
|
||||
// advertises the in-workspace mutators a member needs to build (write/edit +
|
||||
// shell) on top of the Auto set, while the sandbox engine still gates them at
|
||||
// call time — in-workspace writes and sandbox-backed shell auto-allow, but
|
||||
// out-of-workspace writes, network, and destructive commands still prompt (and
|
||||
// a headless member has no approver, so they are denied). It normalizes to Auto
|
||||
// everywhere except ToolAdvertised, so authority is never widened beyond what an
|
||||
// interactive auto agent already has inside the sandbox.
|
||||
PermissionModeMemberAuto PermissionMode = "member-auto"
|
||||
)
|
||||
|
||||
type StopReason string
|
||||
@@ -138,19 +129,11 @@ type PermissionEvent struct {
|
||||
CommandPrefix []string `json:"commandPrefix,omitempty"`
|
||||
}
|
||||
|
||||
// AskUserQuestion is one clarifying question the agent wants answered. Options are
|
||||
// optional suggested answers an interactive front-end can render as a picker;
|
||||
// Recommended (when set) is the suggested default — it should match one of Options.
|
||||
// Header is an optional short tab title for a multi-question prompt (falls back to
|
||||
// the question text). OptionDescriptions, when present, holds a one-line description
|
||||
// per option aligned by index to Options (empty string = no description).
|
||||
// AskUserQuestion is one clarifying question the agent wants answered.
|
||||
type AskUserQuestion struct {
|
||||
Question string `json:"question"`
|
||||
Header string `json:"header,omitempty"`
|
||||
Options []string `json:"options,omitempty"`
|
||||
OptionDescriptions []string `json:"optionDescriptions,omitempty"`
|
||||
Recommended string `json:"recommended,omitempty"`
|
||||
MultiSelect bool `json:"multiSelect,omitempty"`
|
||||
Question string `json:"question"`
|
||||
Options []string `json:"options,omitempty"`
|
||||
MultiSelect bool `json:"multiSelect,omitempty"`
|
||||
}
|
||||
|
||||
// AskUserRequest is handed to OnAskUser when the model invokes the ask_user tool.
|
||||
@@ -270,17 +253,6 @@ type Options struct {
|
||||
// byte-identical to before). One instance per run — it holds attempt state.
|
||||
SelfCorrect *SelfCorrector
|
||||
|
||||
// RequireCompletionSignal gates run completion for HEADLESS exec. Without it,
|
||||
// any assistant turn that produces text but no tool call is accepted as the
|
||||
// final answer. With it, a no-tool-call turn is NOT treated as "done" while
|
||||
// work clearly remains — pending update_plan items, or a message that ends on a
|
||||
// continuation cue ("…Let me check the config:"). The loop then nudges the
|
||||
// model to continue instead, bounded by maxContinueNudges (and still by
|
||||
// MaxTurns and the run deadline); if the model keeps stalling, the run
|
||||
// finalizes as INCOMPLETE (Result.Incomplete) rather than success. Default
|
||||
// false leaves the loop byte-identical, so the interactive TUI is unaffected.
|
||||
RequireCompletionSignal bool
|
||||
|
||||
runPermissions *permissionRunState
|
||||
}
|
||||
|
||||
@@ -294,17 +266,6 @@ type Result struct {
|
||||
// hit the token cap, FinishReasonContentFilter when it was filtered. Empty for
|
||||
// a normal completion.
|
||||
FinishReason string
|
||||
// Incomplete reports that a headless run (RequireCompletionSignal) stopped with
|
||||
// work clearly unfinished: the model ended a turn with no tool call while plan
|
||||
// items were pending or the message ended mid-step, the model admitted it
|
||||
// guessed / could not meet the objective, and/or it failed a task-grounded
|
||||
// acceptance check. Callers map it to a non-success terminal status / exit
|
||||
// code. False for every normal completion.
|
||||
Incomplete bool
|
||||
// IncompleteReason is a short, model-derived explanation of why the run was
|
||||
// marked Incomplete (e.g. "pending plan items remain"). Empty when Incomplete
|
||||
// is false. Surfaced in logs / run_end so an abandoned run is debuggable.
|
||||
IncompleteReason string
|
||||
}
|
||||
|
||||
// Truncated reports whether the final response ended abnormally (cut off at the
|
||||
|
||||
+2
-4
@@ -52,13 +52,11 @@ func runACP(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
engine, err := buildExecSandboxEngine(workspaceRoot, resolved, deps, scope)
|
||||
engine, err := buildExecSandboxEngine(workspaceRoot, resolved, deps, scope, false)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
registry := newCoreRegistryScoped(workspaceRoot, scope)
|
||||
registerLocalControlTools(registry, workspaceRoot, resolved.LocalControl)
|
||||
return registry, engine, nil
|
||||
return newCoreRegistryScoped(workspaceRoot, scope), engine, nil
|
||||
},
|
||||
ResolveWorkspaceRoot: acpWorkspaceRootResolver(deps),
|
||||
Store: deps.newSessionStore(),
|
||||
|
||||
+2
-84
@@ -17,7 +17,6 @@ import (
|
||||
"github.com/Gitlawb/zero/internal/agent"
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/hooks"
|
||||
"github.com/Gitlawb/zero/internal/localcontrol"
|
||||
"github.com/Gitlawb/zero/internal/mcp"
|
||||
"github.com/Gitlawb/zero/internal/observability"
|
||||
"github.com/Gitlawb/zero/internal/plugins"
|
||||
@@ -514,13 +513,6 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
|
||||
if err != nil {
|
||||
return writeAppError(stderr, "failed to resolve user config path: "+err.Error(), 1)
|
||||
}
|
||||
// Fail-soft, one-time: move any inline plaintext API keys in config.json into
|
||||
// the encrypted credential store (interactive runs only; headless exec keeps
|
||||
// its existing behavior). Non-fatal — a missing keyring or write error leaves
|
||||
// the inline key in place and this run still uses the already-resolved key.
|
||||
if store, storeErr := config.ProviderKeyStoreAt(filepath.Dir(userConfigPath)); storeErr == nil {
|
||||
_, _ = config.MigratePlaintextProviderKeys(userConfigPath, store)
|
||||
}
|
||||
doctorUserConfigPath := ""
|
||||
projectConfigPath := ""
|
||||
if resolveOptions, optErr := config.DefaultResolveOptions(workspaceRoot); optErr == nil {
|
||||
@@ -561,7 +553,6 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
|
||||
}
|
||||
|
||||
registry := newCoreRegistryScoped(workspaceRoot, scope)
|
||||
registerLocalControlTools(registry, workspaceRoot, resolved.LocalControl)
|
||||
specialistRuntime, err := registerSpecialistTools(registry, workspaceRoot, resolved.Swarm.MaxTeamSize)
|
||||
if err != nil {
|
||||
return writeAppError(stderr, "failed to initialize specialist tools: "+err.Error(), 1)
|
||||
@@ -647,9 +638,7 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
|
||||
ProviderName: resolved.Provider.Name,
|
||||
ModelName: resolved.Provider.Model,
|
||||
ProviderProfile: resolved.Provider,
|
||||
SavedProviders: usableSavedProviders(resolved.Providers),
|
||||
FavoriteModels: resolved.Preferences.FavoriteModels,
|
||||
RecapsEnabled: resolved.Preferences.RecapsEnabled(),
|
||||
Provider: provider,
|
||||
NewProvider: deps.newProvider,
|
||||
ProbeProviderHealth: deps.probeProviderHealth,
|
||||
@@ -723,16 +712,7 @@ func buildProvider(resolved config.ResolvedConfig, deps appDeps) (zeroruntime.Pr
|
||||
if !config.HasProviderProfile(resolved.Provider) {
|
||||
return nil, nil
|
||||
}
|
||||
// Load the API key from the encrypted credential store when it isn't already
|
||||
// resolved from inline config or an env var. This keeps the resolver pure (no
|
||||
// I/O) and is a no-op for env/inline-key providers and when nothing is stored.
|
||||
profile := resolved.Provider
|
||||
if path, perr := deps.userConfigPath(); perr == nil {
|
||||
if store, err := config.ProviderKeyStoreAt(filepath.Dir(path)); err == nil {
|
||||
profile = config.ApplyStoredAPIKey(profile, store)
|
||||
}
|
||||
}
|
||||
return deps.newProvider(profile)
|
||||
return deps.newProvider(resolved.Provider)
|
||||
}
|
||||
|
||||
func newCoreRegistry(workspaceRoot string) *tools.Registry {
|
||||
@@ -744,71 +724,9 @@ func newCoreRegistryScoped(workspaceRoot string, scope tools.PathScope) *tools.R
|
||||
for _, tool := range tools.CoreToolsScoped(workspaceRoot, scope) {
|
||||
registry.Register(tool)
|
||||
}
|
||||
registerLocalControlTools(registry, workspaceRoot, config.LocalControlConfig{})
|
||||
return registry
|
||||
}
|
||||
|
||||
func registerLocalControlTools(registry *tools.Registry, workspaceRoot string, cfg config.LocalControlConfig) {
|
||||
if registry == nil {
|
||||
return
|
||||
}
|
||||
browserOptions := localBrowserOptionsFromConfig(cfg)
|
||||
desktopOptions := localDesktopOptionsFromConfig(cfg)
|
||||
terminalOptions := localTerminalOptionsFromConfig(cfg)
|
||||
for _, tool := range tools.NewLocalBrowserTools(browserOptions) {
|
||||
registry.Register(tool)
|
||||
}
|
||||
for _, tool := range tools.NewLocalDesktopTools(desktopOptions) {
|
||||
registry.Register(tool)
|
||||
}
|
||||
for _, tool := range tools.NewLocalTerminalTools(terminalOptions) {
|
||||
registry.Register(tool)
|
||||
}
|
||||
for _, tool := range tools.NewLocalControlArtifactTools(tools.LocalControlArtifactOptions{
|
||||
Browser: browserOptions,
|
||||
Desktop: desktopOptions,
|
||||
Terminal: terminalOptions,
|
||||
ArtifactsDir: localArtifactsDirFromConfig(workspaceRoot, cfg),
|
||||
}) {
|
||||
registry.Register(tool)
|
||||
}
|
||||
}
|
||||
|
||||
func localBrowserOptionsFromConfig(cfg config.LocalControlConfig) localcontrol.BrowserOptions {
|
||||
return localcontrol.BrowserOptions{
|
||||
Enabled: cfg.BrowserEnabled(),
|
||||
Driver: cfg.Browser.Driver,
|
||||
HelperPath: cfg.Browser.HelperPath,
|
||||
}
|
||||
}
|
||||
|
||||
func localDesktopOptionsFromConfig(cfg config.LocalControlConfig) localcontrol.DesktopOptions {
|
||||
return localcontrol.DesktopOptions{
|
||||
Enabled: cfg.DesktopEnabled(),
|
||||
Driver: cfg.Desktop.Driver,
|
||||
HelperPath: cfg.Desktop.HelperPath,
|
||||
}
|
||||
}
|
||||
|
||||
func localTerminalOptionsFromConfig(cfg config.LocalControlConfig) localcontrol.TerminalOptions {
|
||||
return localcontrol.TerminalOptions{
|
||||
Enabled: cfg.TerminalEnabled(),
|
||||
Driver: cfg.Terminal.Driver,
|
||||
HelperPath: cfg.Terminal.HelperPath,
|
||||
}
|
||||
}
|
||||
|
||||
func localArtifactsDirFromConfig(workspaceRoot string, cfg config.LocalControlConfig) string {
|
||||
dir := strings.TrimSpace(cfg.ArtifactsDir)
|
||||
if dir == "" {
|
||||
dir = filepath.Join(".zero", "artifacts")
|
||||
}
|
||||
if filepath.IsAbs(dir) {
|
||||
return dir
|
||||
}
|
||||
return filepath.Join(workspaceRoot, dir)
|
||||
}
|
||||
|
||||
// agentToolRuntime bundles the specialist runtime with the swarm it backs so
|
||||
// their lifetimes (and shutdown) stay paired: registerSpecialistTools brings up
|
||||
// both, closeSpecialistRuntime tears down both.
|
||||
@@ -1068,7 +986,7 @@ func splitLeadingThemeFlag(args []string) (string, []string, error) {
|
||||
// setup result is not env-resolved, so checking APIKey alone would wrongly flag every
|
||||
// env-var-based provider as keyless.
|
||||
func profileHasCredential(profile config.ProviderProfile) bool {
|
||||
if strings.TrimSpace(profile.APIKey) != "" || profile.APIKeyStored || strings.TrimSpace(profile.AuthHeaderValue) != "" {
|
||||
if strings.TrimSpace(profile.APIKey) != "" || strings.TrimSpace(profile.AuthHeaderValue) != "" {
|
||||
return true
|
||||
}
|
||||
if env := strings.TrimSpace(profile.APIKeyEnv); env != "" {
|
||||
|
||||
@@ -617,6 +617,9 @@ func TestRunAddDirWiresExtraWriteRootIntoTUISandboxScope(t *testing.T) {
|
||||
t.Fatal("AgentOptions.Sandbox = nil, want sandbox engine")
|
||||
}
|
||||
roots := launchedOptions.AgentOptions.Sandbox.Scope().Roots()
|
||||
if len(roots) != 2 {
|
||||
t.Fatalf("scope roots = %v, want exactly workspace + extra", roots)
|
||||
}
|
||||
// The scope stores symlink-resolved roots (e.g. macOS /var -> /private/var),
|
||||
// so compare against the resolved extra dir.
|
||||
resolvedExtra, err := filepath.EvalSymlinks(extra)
|
||||
@@ -676,14 +679,10 @@ func TestRunSkipPermissionsUnsafeMergesAddDirGrants(t *testing.T) {
|
||||
t.Fatal("AgentOptions.Sandbox = nil, want sandbox engine")
|
||||
}
|
||||
roots := launchedOptions.AgentOptions.Sandbox.Scope().Roots()
|
||||
found := false
|
||||
for _, root := range roots {
|
||||
if root == resolvedExtra {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
if len(roots) != 2 {
|
||||
t.Fatalf("scope roots = %v, want exactly workspace + extra", roots)
|
||||
}
|
||||
if !found {
|
||||
if roots[1] != resolvedExtra {
|
||||
t.Fatalf("scope roots = %v, want extra root %q", roots, resolvedExtra)
|
||||
}
|
||||
})
|
||||
|
||||
+2
-16
@@ -9,7 +9,6 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/oauth"
|
||||
"github.com/Gitlawb/zero/internal/provideroauth"
|
||||
"github.com/Gitlawb/zero/internal/redaction"
|
||||
@@ -138,7 +137,7 @@ func runAuthChatGPT(args []string, stdout io.Writer, stderr io.Writer, deps appD
|
||||
if _, err := fmt.Fprint(stdout, statuses); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
if _, err := fmt.Fprint(stdout, "\nUse it with zero, e.g.:\n zero --provider chatgpt --model gpt-5.5\n"); err != nil {
|
||||
if _, err := fmt.Fprint(stdout, "\nUse it with zero, e.g.:\n zero --provider chatgpt --model gpt-5\n"); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
@@ -337,19 +336,6 @@ func runAuthLogout(args []string, stdout io.Writer, stderr io.Writer, deps appDe
|
||||
if err != nil {
|
||||
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
|
||||
}
|
||||
// Also drop any stored API key and its marker so `auth logout` clears the whole
|
||||
// credential (OAuth token AND key), not just the OAuth side. Surface deletion
|
||||
// failures rather than reporting success while a credential remains.
|
||||
keyRemoved, keyErr := config.ForgetProviderKey(provider)
|
||||
if keyErr != nil {
|
||||
return writeAppError(stderr, redaction.ErrorMessage(keyErr, redaction.Options{}), exitCrash)
|
||||
}
|
||||
if configPath, perr := deps.userConfigPath(); perr == nil {
|
||||
if _, clearErr := config.ClearProviderKeyStored(configPath, provider); clearErr != nil {
|
||||
return writeAppError(stderr, redaction.ErrorMessage(clearErr, redaction.Options{}), exitCrash)
|
||||
}
|
||||
}
|
||||
removed = removed || keyRemoved
|
||||
if parsed.json {
|
||||
payload := struct {
|
||||
Provider string `json:"provider"`
|
||||
@@ -360,7 +346,7 @@ func runAuthLogout(args []string, stdout io.Writer, stderr io.Writer, deps appDe
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
msg := fmt.Sprintf("No stored credential for %s.\n", provider)
|
||||
msg := fmt.Sprintf("No OAuth login stored for %s.\n", provider)
|
||||
if removed {
|
||||
msg = fmt.Sprintf("Logged out of %s.\n", provider)
|
||||
}
|
||||
|
||||
@@ -72,7 +72,7 @@ func TestRunAuthLogoutNothing(t *testing.T) {
|
||||
if code := runWithDeps([]string{"auth", "logout", "demo"}, &stdout, &stderr, appDeps{}); code != exitSuccess {
|
||||
t.Fatalf("exit = %d stderr=%s", code, stderr.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "No stored credential for demo") {
|
||||
if !strings.Contains(stdout.String(), "No OAuth login stored for demo") {
|
||||
t.Fatalf("logout output = %q", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -127,8 +128,8 @@ func TestValidateExecToolFiltersAllowsToolSearch(t *testing.T) {
|
||||
// TestRunExecListToolsAdvertisesMCPToolsWithoutToolSearch verifies that
|
||||
// `exec --list-tools` lists the core + MCP tools WITHOUT tool_search. This is
|
||||
// independent of the deferral threshold: --list-tools short-circuits and returns
|
||||
// before registerToolSearchIfEligible runs (see exec.go), so tool_search is
|
||||
// never registered on this path. The threshold gate itself is
|
||||
// before resolveConfig and registerToolSearchIfEligible ever run (see exec.go),
|
||||
// so tool_search is never registered on this path. The threshold gate itself is
|
||||
// exercised by TestRegisterToolSearchIfEligible{RegistersAtThreshold,
|
||||
// SkipsBelowThreshold,SkipsWhenThresholdZero} and the end-to-end at-threshold
|
||||
// path in TestTUIRunThreadsDeferThresholdAndRegistersToolSearch.
|
||||
@@ -140,7 +141,7 @@ func TestRunExecListToolsAdvertisesMCPToolsWithoutToolSearch(t *testing.T) {
|
||||
exitCode := runWithDeps([]string{"exec", "--list-tools"}, &stdout, &stderr, appDeps{
|
||||
getwd: func() (string, error) { return cwd, nil },
|
||||
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
|
||||
return execResolvedConfig(), nil
|
||||
return config.ResolvedConfig{}, errors.New("provider should not be resolved for --list-tools")
|
||||
},
|
||||
resolveMCPConfig: func(string) (config.MCPConfig, error) {
|
||||
return config.MCPConfig{Servers: map[string]config.MCPServerConfig{
|
||||
@@ -175,7 +176,7 @@ func TestRunExecListToolsHonorsJSONFormat(t *testing.T) {
|
||||
exitCode := runWithDeps([]string{"exec", "--list-tools", "-o", "json"}, &stdout, &stderr, appDeps{
|
||||
getwd: func() (string, error) { return cwd, nil },
|
||||
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
|
||||
return execResolvedConfig(), nil
|
||||
return config.ResolvedConfig{}, errors.New("provider should not be resolved for --list-tools")
|
||||
},
|
||||
resolveMCPConfig: func(string) (config.MCPConfig, error) { return config.MCPConfig{}, nil },
|
||||
newMCPStore: func() (*mcp.PermissionStore, error) { return nil, nil },
|
||||
|
||||
+141
-165
@@ -16,8 +16,6 @@ import (
|
||||
"github.com/Gitlawb/zero/internal/lsp"
|
||||
"github.com/Gitlawb/zero/internal/modelregistry"
|
||||
"github.com/Gitlawb/zero/internal/notify"
|
||||
"github.com/Gitlawb/zero/internal/providercatalog"
|
||||
"github.com/Gitlawb/zero/internal/providermodeldiscovery"
|
||||
"github.com/Gitlawb/zero/internal/providers"
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
"github.com/Gitlawb/zero/internal/sessions"
|
||||
@@ -34,12 +32,6 @@ const (
|
||||
exitCrash = 1
|
||||
exitUsage = 2
|
||||
exitProvider = 3
|
||||
// exitIncomplete marks a headless run that stopped with work clearly
|
||||
// unfinished — the completion gate exhausted its continue budget on a
|
||||
// no-tool-call turn while plan items were pending or the model ended mid-step.
|
||||
// Distinct from success so callers/benchmarks don't treat an abandoned run as
|
||||
// finished.
|
||||
exitIncomplete = 4
|
||||
// exitInterrupted is the conventional shell exit code for a process stopped by
|
||||
// SIGINT (128 + signal number 2).
|
||||
exitInterrupted = 130
|
||||
@@ -113,6 +105,16 @@ type execOptions struct {
|
||||
// additional write roots for this run. Unioned with
|
||||
// config.SandboxConfig.AdditionalWriteRoots at scope construction time.
|
||||
addDirs []string
|
||||
// sandboxMode is the raw --sandbox flag value ("" = default enforce, "off"/
|
||||
// "disabled" = container-trusted). Resolved (with the ZERO_SANDBOX env
|
||||
// fallback) into a disable decision in runExec. Off by default; the disabled
|
||||
// path is an EXPLICIT, loudly-warned opt-in meant ONLY for use inside an
|
||||
// already-isolated container (CI / benchmark).
|
||||
sandboxMode string
|
||||
// deadlineRaw is the raw --deadline flag value (a Go duration like "900s").
|
||||
// Resolved (with the ZERO_DEADLINE env fallback) into a wall-clock budget for
|
||||
// the run. Empty = no deadline (current behavior).
|
||||
deadlineRaw string
|
||||
}
|
||||
|
||||
type execUsageError struct {
|
||||
@@ -208,44 +210,6 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
|
||||
if options.useSpec {
|
||||
specmode.RegisterDraftTools(registry, workspaceRoot, deps.now)
|
||||
}
|
||||
// Build the model registry once and reuse it across every model-aware
|
||||
// lookup in this exec run (model resolution, reasoning-effort advisory, and
|
||||
// context-window sizing). DefaultRegistry builds the full catalog and
|
||||
// compiles its match patterns, so rebuilding it per lookup is wasteful.
|
||||
// modelRegistry is the zero Registry when the catalog fails to build; the
|
||||
// helpers below degrade to safe no-op behavior in that case.
|
||||
modelRegistry, _ := modelregistry.DefaultRegistry()
|
||||
|
||||
overrides := config.Overrides{}
|
||||
modelOverride := options.model
|
||||
if options.useSpec && options.specModel != "" {
|
||||
modelOverride = options.specModel
|
||||
}
|
||||
if modelOverride != "" {
|
||||
resolvedModel, notice := resolveSelectedModel(modelRegistry, modelOverride)
|
||||
overrides.Provider.Model = resolvedModel
|
||||
if notice != "" {
|
||||
if _, err := fmt.Fprintln(stderr, notice); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
}
|
||||
}
|
||||
if options.maxTurns > 0 {
|
||||
overrides.MaxTurns = options.maxTurns
|
||||
}
|
||||
resolved, err := deps.resolveConfig(workspaceRoot, overrides)
|
||||
if err != nil {
|
||||
if !options.listTools {
|
||||
if preflightErr := preflightExecSession(options); preflightErr != nil {
|
||||
return writeExecFormatUsageError(stdout, stderr, options.outputFormat, preflightErr.Error())
|
||||
}
|
||||
if _, _, promptErr := resolveExecPrompt(options, workspaceRoot, deps.stdin); promptErr != nil {
|
||||
return writeExecFormatUsageError(stdout, stderr, options.outputFormat, promptErr.Error())
|
||||
}
|
||||
}
|
||||
return writeExecProviderError(stdout, stderr, options.outputFormat, "provider_error", err.Error())
|
||||
}
|
||||
registerLocalControlTools(registry, workspaceRoot, resolved.LocalControl)
|
||||
if err := validateExecToolFilters(options, registry); err != nil {
|
||||
return writeExecFormatUsageError(stdout, stderr, options.outputFormat, err.Error())
|
||||
}
|
||||
@@ -274,6 +238,35 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
|
||||
}
|
||||
sessionTitle := execSessionTitle(options, prompt)
|
||||
|
||||
// Build the model registry once and reuse it across every model-aware
|
||||
// lookup in this exec run (model resolution, reasoning-effort advisory, and
|
||||
// context-window sizing). DefaultRegistry builds the full catalog and
|
||||
// compiles its match patterns, so rebuilding it per lookup is wasteful.
|
||||
// modelRegistry is the zero Registry when the catalog fails to build; the
|
||||
// helpers below degrade to safe no-op behavior in that case.
|
||||
modelRegistry, _ := modelregistry.DefaultRegistry()
|
||||
|
||||
overrides := config.Overrides{}
|
||||
modelOverride := options.model
|
||||
if options.useSpec && options.specModel != "" {
|
||||
modelOverride = options.specModel
|
||||
}
|
||||
if modelOverride != "" {
|
||||
resolvedModel, notice := resolveSelectedModel(modelRegistry, modelOverride)
|
||||
overrides.Provider.Model = resolvedModel
|
||||
if notice != "" {
|
||||
if _, err := fmt.Fprintln(stderr, notice); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
}
|
||||
}
|
||||
if options.maxTurns > 0 {
|
||||
overrides.MaxTurns = options.maxTurns
|
||||
}
|
||||
resolved, err := deps.resolveConfig(workspaceRoot, overrides)
|
||||
if err != nil {
|
||||
return writeExecProviderError(stdout, stderr, options.outputFormat, "provider_error", err.Error())
|
||||
}
|
||||
if !config.HasProviderProfile(resolved.Provider) {
|
||||
return writeExecProviderError(stdout, stderr, options.outputFormat, "provider_error", "No provider configured. Run `zero setup` (guided), `zero auth` (OAuth providers), set a provider API key env var (e.g. OPENAI_API_KEY / ANTHROPIC_API_KEY / GEMINI_API_KEY), or add .zero/config.json.")
|
||||
}
|
||||
@@ -330,7 +323,8 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
|
||||
for _, tool := range tools.CoreToolsScoped(workspaceRoot, execScope) {
|
||||
registry.Register(tool)
|
||||
}
|
||||
sandboxEngine, err := buildExecSandboxEngine(workspaceRoot, resolved, deps, execScope)
|
||||
sandboxOff := resolveSandboxOff(options.sandboxMode)
|
||||
sandboxEngine, err := buildExecSandboxEngine(workspaceRoot, resolved, deps, execScope, sandboxOff)
|
||||
if err != nil {
|
||||
return writeExecProviderError(stdout, stderr, options.outputFormat, "sandbox_error", err.Error())
|
||||
}
|
||||
@@ -475,6 +469,12 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
|
||||
return exitCrash
|
||||
}
|
||||
}
|
||||
if sandboxOff {
|
||||
writer.warning("Sandbox disabled (container-trusted mode): filesystem and network are unrestricted. Use only inside an isolated container.")
|
||||
if writer.err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
}
|
||||
|
||||
sessionRecorder := execSessionRecorder{prepared: preparedSession}
|
||||
// Surface a best-effort session-recording failure once, on every exit path.
|
||||
@@ -492,6 +492,15 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
|
||||
// cleanly (the loop honors context cancellation) instead of being killed.
|
||||
runCtx, stopSignals := signalContext()
|
||||
defer stopSignals()
|
||||
// --deadline / ZERO_DEADLINE bound the run by wall-clock time (Terminal-Bench
|
||||
// budgets by time, not turns). When the deadline fires, runCtx is cancelled,
|
||||
// the loop stops, and the run_end below reports status "deadline" with the
|
||||
// trajectory already flushed. Empty = no deadline (unchanged behavior).
|
||||
if runDeadline := resolveDeadline(options.deadlineRaw); runDeadline > 0 {
|
||||
var cancelDeadline context.CancelFunc
|
||||
runCtx, cancelDeadline = context.WithTimeout(runCtx, runDeadline)
|
||||
defer cancelDeadline()
|
||||
}
|
||||
// --self-correct opts the run into the post-edit verify-and-correct loop. Off
|
||||
// by default the corrector is nil, leaving the agent loop byte-identical. When
|
||||
// on we verify with both the workspace test plan and LSP diagnostics over the
|
||||
@@ -502,7 +511,7 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
|
||||
defer lspShutdown()
|
||||
result, err := agent.Run(runCtx, agentPrompt, provider, agent.Options{
|
||||
MaxTurns: resolved.MaxTurns,
|
||||
ContextWindow: resolveAgentContextWindow(runCtx, modelRegistry, resolved.Provider),
|
||||
ContextWindow: modelContextWindow(modelRegistry, resolved.Provider.Model),
|
||||
DeferThreshold: effectiveDeferThreshold,
|
||||
Specialists: specialistRuntime.specialistInfos(),
|
||||
SessionID: preparedSession.Session.SessionID,
|
||||
@@ -521,17 +530,12 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
|
||||
PermissionMode: permissionMode,
|
||||
Autonomy: options.autonomy,
|
||||
SelfCorrect: selfCorrector,
|
||||
// Headless exec: don't accept a no-tool-call turn as "done" while work
|
||||
// clearly remains (pending plan items / a mid-step continuation cue) —
|
||||
// nudge to continue, and finalize as INCOMPLETE rather than false success
|
||||
// if the model keeps stalling. The interactive TUI leaves this off.
|
||||
RequireCompletionSignal: true,
|
||||
Sandbox: sandboxEngine,
|
||||
FileTracker: tools.NewFileTracker(),
|
||||
Hooks: newHookDispatcherWithExtra(workspaceRoot, pluginActivation.hooks),
|
||||
EnabledTools: options.enabledTools,
|
||||
DisabledTools: options.disabledTools,
|
||||
OnText: writer.text,
|
||||
Sandbox: sandboxEngine,
|
||||
FileTracker: tools.NewFileTracker(),
|
||||
Hooks: newHookDispatcherWithExtra(workspaceRoot, pluginActivation.hooks),
|
||||
EnabledTools: options.enabledTools,
|
||||
DisabledTools: options.disabledTools,
|
||||
OnText: writer.text,
|
||||
OnToolCall: func(call agent.ToolCall) {
|
||||
writer.toolCall(call, registry)
|
||||
sessionRecorder.append(sessions.EventToolCall, map[string]any{
|
||||
@@ -587,11 +591,18 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
|
||||
if err != nil {
|
||||
// A Ctrl+C / SIGTERM cancellation is a clean shutdown, not a provider error.
|
||||
if errors.Is(err, context.Canceled) || runCtx.Err() != nil {
|
||||
sessionRecorder.append(sessions.EventError, map[string]any{"message": "interrupted"})
|
||||
// Distinguish a wall-clock --deadline stop (status "deadline") from a
|
||||
// Ctrl+C / SIGTERM interrupt; both are clean shutdowns, not errors, and
|
||||
// the trajectory is flushed before exit either way.
|
||||
cancelStatus, cancelMsg := "interrupted", "run cancelled by signal"
|
||||
if errors.Is(runCtx.Err(), context.DeadlineExceeded) {
|
||||
cancelStatus, cancelMsg = "deadline", "run stopped: wall-clock deadline reached"
|
||||
}
|
||||
sessionRecorder.append(sessions.EventError, map[string]any{"message": cancelStatus})
|
||||
switch options.outputFormat {
|
||||
case execOutputStreamJSON:
|
||||
writer.errorEvent("interrupted", "run cancelled by signal", false)
|
||||
writer.runEnd("interrupted", exitInterrupted)
|
||||
writer.errorEvent(cancelStatus, cancelMsg, false)
|
||||
writer.runEnd(cancelStatus, exitInterrupted)
|
||||
if writer.err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
@@ -635,43 +646,6 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
|
||||
if notice := result.TruncationNotice(); notice != "" {
|
||||
writer.warning(notice)
|
||||
}
|
||||
// A headless run the completion gate marked INCOMPLETE (no-tool-call stall,
|
||||
// self-reported non-completion, or max-turns cutoff) must NOT be reported as a
|
||||
// success: exit 4 AND a machine-readable terminal event saying so. Handled per
|
||||
// output format BEFORE writer.final(), because for -o json final() emits a
|
||||
// {"type":"done","exit_code":0} that would otherwise mask the incomplete exit.
|
||||
// An `error` event (not just a warning) is emitted so log/cron consumers that
|
||||
// scan for type=="error" can recover the reason.
|
||||
if result.Incomplete {
|
||||
reason := result.IncompleteReason
|
||||
if reason == "" {
|
||||
reason = "run stopped with work unfinished"
|
||||
}
|
||||
sessionRecorder.append(sessions.EventError, map[string]any{"message": "incomplete: " + reason})
|
||||
switch options.outputFormat {
|
||||
case execOutputStreamJSON:
|
||||
writer.final(result.FinalAnswer)
|
||||
writer.errorEvent("incomplete", reason, false)
|
||||
writer.runEnd("incomplete", exitIncomplete)
|
||||
if writer.err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
case execOutputJSON:
|
||||
if writeErr := writeJSONLine(stdout, map[string]any{"type": "final", "text": result.FinalAnswer}); writeErr != nil {
|
||||
return exitCrash
|
||||
}
|
||||
if writeErr := writeJSONLine(stdout, map[string]any{"type": "error", "code": "incomplete", "message": reason}); writeErr != nil {
|
||||
return exitCrash
|
||||
}
|
||||
if writeErr := writeJSONLine(stdout, map[string]any{"type": "done", "exit_code": exitIncomplete}); writeErr != nil {
|
||||
return exitCrash
|
||||
}
|
||||
default:
|
||||
writer.final(result.FinalAnswer)
|
||||
fmt.Fprintln(stderr, "Run incomplete (not reported as success): "+reason)
|
||||
}
|
||||
return exitIncomplete
|
||||
}
|
||||
writer.final(result.FinalAnswer)
|
||||
writer.runEnd("success", exitSuccess)
|
||||
if writer.err != nil {
|
||||
@@ -718,11 +692,7 @@ func newExecSelfCorrector(enabled bool, workspaceRoot string, autonomy string) (
|
||||
func deferredEligibleCount(registry *tools.Registry, permissionMode agent.PermissionMode, enabledTools []string, disabledTools []string) int {
|
||||
count := 0
|
||||
for _, tool := range registry.All() {
|
||||
// Count by deferral-eligibility (not current deferred state) to match
|
||||
// partitionTools' active-gate: a tool that un-defers at runtime still
|
||||
// participates in the threshold. At registration time the swarm is empty
|
||||
// so this equals IsDeferred, but it keeps the two count sites consistent.
|
||||
if !tools.IsDeferralEligible(tool) {
|
||||
if !tools.IsDeferred(tool) {
|
||||
continue
|
||||
}
|
||||
if !agent.ToolVisible(tool, permissionMode, enabledTools, disabledTools) {
|
||||
@@ -751,12 +721,63 @@ func registerToolSearchIfEligible(registry *tools.Registry, deferThreshold int,
|
||||
registry.Register(tools.NewToolSearchTool(registry))
|
||||
}
|
||||
|
||||
func buildExecSandboxEngine(workspaceRoot string, resolved config.ResolvedConfig, deps appDeps, scope *sandbox.Scope) (*sandbox.Engine, error) {
|
||||
// resolveSandboxOff decides whether to disable confinement for an exec run.
|
||||
// Precedence: the --sandbox flag value wins; otherwise the ZERO_SANDBOX env var.
|
||||
// Recognized "off" values: off / disabled / none / 0 (case-insensitive). Anything
|
||||
// else, including empty, keeps the default enforcing sandbox.
|
||||
func resolveSandboxOff(flagValue string) bool {
|
||||
value := strings.TrimSpace(flagValue)
|
||||
if value == "" {
|
||||
value = strings.TrimSpace(os.Getenv("ZERO_SANDBOX"))
|
||||
}
|
||||
switch strings.ToLower(value) {
|
||||
case "off", "disabled", "none", "0":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// resolveDeadline parses the run's wall-clock budget. Precedence: the --deadline
|
||||
// flag wins; otherwise the ZERO_DEADLINE env var. Empty = no deadline. An
|
||||
// unparseable or non-positive value is treated as no deadline.
|
||||
func resolveDeadline(flagValue string) time.Duration {
|
||||
value := strings.TrimSpace(flagValue)
|
||||
if value == "" {
|
||||
value = strings.TrimSpace(os.Getenv("ZERO_DEADLINE"))
|
||||
}
|
||||
if value == "" {
|
||||
return 0
|
||||
}
|
||||
d, err := time.ParseDuration(value)
|
||||
if err != nil || d <= 0 {
|
||||
return 0
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// execSandboxPolicy builds the sandbox policy for an exec run. By default it is
|
||||
// the configured, enforcing policy (DefaultPolicy + config overlay). With
|
||||
// disableSandbox it becomes container-trusted: Mode=disabled, Network=allow,
|
||||
// EnforceWorkspace=false — flipping all three gates so both the in-process
|
||||
// Evaluate path and the OS wrapping are permissive. Pure, so the default-unchanged
|
||||
// and off-mode behavior are unit-tested directly.
|
||||
func execSandboxPolicy(cfg config.SandboxConfig, disableSandbox bool) sandbox.Policy {
|
||||
policy := applyConfiguredSandboxPolicy(sandbox.DefaultPolicy(), cfg)
|
||||
if disableSandbox {
|
||||
policy.Mode = sandbox.ModeDisabled
|
||||
policy.Network = sandbox.NetworkAllow
|
||||
policy.EnforceWorkspace = false
|
||||
}
|
||||
return policy
|
||||
}
|
||||
|
||||
func buildExecSandboxEngine(workspaceRoot string, resolved config.ResolvedConfig, deps appDeps, scope *sandbox.Scope, disableSandbox bool) (*sandbox.Engine, error) {
|
||||
store, err := deps.newSandboxStore()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
policy := applyConfiguredSandboxPolicy(sandbox.DefaultPolicy(), resolved.Sandbox)
|
||||
policy := execSandboxPolicy(resolved.Sandbox, disableSandbox)
|
||||
backend := deps.selectSandboxBackend(sandbox.BackendOptions{})
|
||||
return sandbox.NewEngine(sandbox.EngineOptions{
|
||||
WorkspaceRoot: workspaceRoot,
|
||||
@@ -1000,71 +1021,30 @@ func resolveSelectedModel(registry modelregistry.Registry, input string) (string
|
||||
return entry.ID, notice
|
||||
}
|
||||
|
||||
// modelContextWindow returns the resolved model's exact context window (max input
|
||||
// tokens) from the registry, or 0 when the model isn't catalogued. Compaction call
|
||||
// sites wrap this in modelregistry.AgentContextWindow to apply a positive fallback;
|
||||
// display/report sites use the raw value so an unknown model shows no denominator.
|
||||
// modelContextWindow returns the resolved model's context window (max input
|
||||
// tokens) from the model registry, used to enable agent-loop compaction. An
|
||||
// unknown model (e.g. a custom openai-compatible name) returns 0, which leaves
|
||||
// compaction DISABLED — a safe default that never compacts unexpectedly.
|
||||
func modelContextWindow(registry modelregistry.Registry, modelID string) int {
|
||||
trimmed := strings.TrimSpace(modelID)
|
||||
if trimmed == "" {
|
||||
return 0
|
||||
}
|
||||
if entry, ok := registry.Resolve(trimmed); ok {
|
||||
return entry.ContextLimits.ContextWindow
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// resolveAgentContextWindow returns the context window used to enable/size agent
|
||||
// compaction: the exact registry value when catalogued, else a window learned from
|
||||
// live provider discovery (so an uncatalogued proxy/custom model gets its real
|
||||
// window instead of the generic fallback), else the positive FallbackContextWindow.
|
||||
// Discovery runs only on a registry miss (catalogued models pay no latency), is
|
||||
// bounded by a short timeout, and degrades to the fallback on any error — so a
|
||||
// headless run is never blocked or failed by it.
|
||||
func resolveAgentContextWindow(ctx context.Context, registry modelregistry.Registry, profile config.ProviderProfile) int {
|
||||
if window := modelContextWindow(registry, profile.Model); window > 0 {
|
||||
return window
|
||||
}
|
||||
if window := discoveredModelContextWindow(ctx, profile); window > 0 {
|
||||
return window
|
||||
}
|
||||
return modelregistry.AgentContextWindow(0)
|
||||
}
|
||||
|
||||
// discoveredModelContextWindow queries the provider's live model list for the
|
||||
// active model's context window. Returns 0 when the provider isn't catalogued, the
|
||||
// credential can't be resolved, discovery fails, or the model reports no window.
|
||||
func discoveredModelContextWindow(ctx context.Context, profile config.ProviderProfile) int {
|
||||
descriptor, ok := providercatalog.Get(strings.TrimSpace(profile.CatalogID))
|
||||
entry, ok := registry.Resolve(trimmed)
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
// Authenticate discovery with the resolved key (inline, then stored, then env).
|
||||
authed := profile
|
||||
if strings.TrimSpace(authed.APIKey) == "" {
|
||||
if store, err := config.ProviderKeyStore(); err == nil {
|
||||
authed = config.ApplyStoredAPIKey(authed, store)
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(authed.APIKey) == "" && strings.TrimSpace(authed.APIKeyEnv) != "" {
|
||||
authed.APIKey = strings.TrimSpace(os.Getenv(authed.APIKeyEnv))
|
||||
}
|
||||
dctx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
models, err := providermodeldiscovery.DiscoverCatalog(dctx, descriptor, authed, providermodeldiscovery.Options{})
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
target := strings.TrimSpace(profile.Model)
|
||||
for _, model := range models {
|
||||
if strings.EqualFold(strings.TrimSpace(model.ID), target) && model.ContextWindow > 0 {
|
||||
return model.ContextWindow
|
||||
}
|
||||
}
|
||||
return 0
|
||||
return entry.ContextLimits.ContextWindow
|
||||
}
|
||||
|
||||
// reasoningEffortNotice resolves the requested --reasoning-effort against the
|
||||
// selected model's supported efforts via EffectiveReasoningEffort and returns a
|
||||
// short advisory when the requested value is unsupported (and was coerced to the
|
||||
// model default).
|
||||
//
|
||||
// NOTE: the effective effort is not yet forwarded to the provider request — the
|
||||
// zeroruntime.CompletionRequest / provider wire schemas carry no effort field.
|
||||
// Full provider-request propagation is deferred (see slice-3 report).
|
||||
// forwardedReasoningEffort returns the effort to send on the provider request.
|
||||
// It mirrors reasoningEffortNotice: a known model that does not support reasoning
|
||||
// yields "" (matching the "ignoring" advisory, so the request never carries an
|
||||
@@ -1087,10 +1067,6 @@ func forwardedReasoningEffort(registry modelregistry.Registry, modelID string, r
|
||||
return string(effective)
|
||||
}
|
||||
|
||||
// reasoningEffortNotice resolves the requested --reasoning-effort against the
|
||||
// selected model's supported efforts via EffectiveReasoningEffort and returns a
|
||||
// short advisory when the requested value is unsupported (and was coerced to the
|
||||
// model default).
|
||||
func reasoningEffortNotice(registry modelregistry.Registry, modelID string, requested string) string {
|
||||
trimmed := strings.TrimSpace(modelID)
|
||||
if trimmed == "" {
|
||||
|
||||
@@ -52,6 +52,24 @@ func parseExecArgs(args []string) (execOptions, bool, error) {
|
||||
index = next
|
||||
case strings.HasPrefix(arg, "--auto="):
|
||||
options.autonomy = strings.TrimSpace(strings.TrimPrefix(arg, "--auto="))
|
||||
case arg == "--sandbox":
|
||||
value, next, err := nextFlagValue(args, index, arg)
|
||||
if err != nil {
|
||||
return options, false, err
|
||||
}
|
||||
options.sandboxMode = strings.TrimSpace(value)
|
||||
index = next
|
||||
case strings.HasPrefix(arg, "--sandbox="):
|
||||
options.sandboxMode = strings.TrimSpace(strings.TrimPrefix(arg, "--sandbox="))
|
||||
case arg == "--deadline":
|
||||
value, next, err := nextFlagValue(args, index, arg)
|
||||
if err != nil {
|
||||
return options, false, err
|
||||
}
|
||||
options.deadlineRaw = strings.TrimSpace(value)
|
||||
index = next
|
||||
case strings.HasPrefix(arg, "--deadline="):
|
||||
options.deadlineRaw = strings.TrimSpace(strings.TrimPrefix(arg, "--deadline="))
|
||||
case arg == "--enabled-tools":
|
||||
value, next, err := nextFlagValue(args, index, arg)
|
||||
if err != nil {
|
||||
|
||||
@@ -51,18 +51,13 @@ func TestRunExecListsFilteredToolsWithoutPromptOrProvider(t *testing.T) {
|
||||
cwd := t.TempDir()
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
providerBuilt := false
|
||||
|
||||
exitCode := runWithDeps([]string{"exec", "--list-tools", "--enabled-tools", "read_file,grep"}, &stdout, &stderr, appDeps{
|
||||
getwd: func() (string, error) {
|
||||
return cwd, nil
|
||||
},
|
||||
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
|
||||
return execResolvedConfig(), nil
|
||||
},
|
||||
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
|
||||
providerBuilt = true
|
||||
return nil, errors.New("provider should not be constructed for --list-tools")
|
||||
return config.ResolvedConfig{}, errors.New("provider should not be resolved for --list-tools")
|
||||
},
|
||||
})
|
||||
|
||||
@@ -72,9 +67,6 @@ func TestRunExecListsFilteredToolsWithoutPromptOrProvider(t *testing.T) {
|
||||
if stderr.Len() != 0 {
|
||||
t.Fatalf("expected empty stderr, got %q", stderr.String())
|
||||
}
|
||||
if providerBuilt {
|
||||
t.Fatal("provider should not be constructed for --list-tools")
|
||||
}
|
||||
for _, want := range []string{"Tools visible to model", "read_file", "grep"} {
|
||||
if !strings.Contains(stdout.String(), want) {
|
||||
t.Fatalf("expected tool list to contain %q, got %q", want, stdout.String())
|
||||
@@ -89,18 +81,13 @@ func TestRunExecListsToolsAsStreamJSONWhenRequested(t *testing.T) {
|
||||
cwd := t.TempDir()
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
providerBuilt := false
|
||||
|
||||
exitCode := runWithDeps([]string{"exec", "--list-tools", "--output-format", "stream-json", "--enabled-tools", "read_file"}, &stdout, &stderr, appDeps{
|
||||
getwd: func() (string, error) {
|
||||
return cwd, nil
|
||||
},
|
||||
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
|
||||
return execResolvedConfig(), nil
|
||||
},
|
||||
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
|
||||
providerBuilt = true
|
||||
return nil, errors.New("provider should not be constructed for --list-tools")
|
||||
return config.ResolvedConfig{}, errors.New("provider should not be resolved for --list-tools")
|
||||
},
|
||||
})
|
||||
|
||||
@@ -110,9 +97,6 @@ func TestRunExecListsToolsAsStreamJSONWhenRequested(t *testing.T) {
|
||||
if stderr.Len() != 0 {
|
||||
t.Fatalf("expected empty stderr, got %q", stderr.String())
|
||||
}
|
||||
if providerBuilt {
|
||||
t.Fatal("provider should not be constructed for --list-tools")
|
||||
}
|
||||
events := decodeJSONLines(t, stdout.String())
|
||||
if len(events) != 3 {
|
||||
t.Fatalf("expected run_start, final, run_end events, got %#v", events)
|
||||
@@ -131,11 +115,11 @@ func TestRunExecListsToolsAsStreamJSONWhenRequested(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunExecListsMCPToolsWithoutProviderConstruction(t *testing.T) {
|
||||
func TestRunExecListsMCPToolsWithoutProviderResolution(t *testing.T) {
|
||||
cwd := t.TempDir()
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
providerBuilt := false
|
||||
providerResolved := false
|
||||
closed := false
|
||||
|
||||
exitCode := runWithDeps([]string{"exec", "--list-tools", "--enabled-tools", "mcp_docs_lookup"}, &stdout, &stderr, appDeps{
|
||||
@@ -143,11 +127,8 @@ func TestRunExecListsMCPToolsWithoutProviderConstruction(t *testing.T) {
|
||||
return cwd, nil
|
||||
},
|
||||
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
|
||||
return execResolvedConfig(), nil
|
||||
},
|
||||
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
|
||||
providerBuilt = true
|
||||
return nil, errors.New("provider should not be constructed for --list-tools")
|
||||
providerResolved = true
|
||||
return config.ResolvedConfig{}, errors.New("provider should not be resolved for --list-tools")
|
||||
},
|
||||
resolveMCPConfig: func(workspaceRoot string) (config.MCPConfig, error) {
|
||||
if workspaceRoot != cwd {
|
||||
@@ -169,8 +150,8 @@ func TestRunExecListsMCPToolsWithoutProviderConstruction(t *testing.T) {
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
|
||||
}
|
||||
if providerBuilt {
|
||||
t.Fatal("provider should not be constructed for --list-tools")
|
||||
if providerResolved {
|
||||
t.Fatal("provider config should not be resolved for --list-tools")
|
||||
}
|
||||
if !closed {
|
||||
t.Fatal("MCP runtime was not closed after --list-tools")
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
)
|
||||
|
||||
// TestExecSandboxPolicyDefaultUnchanged is the load-bearing guard for real users:
|
||||
// WITHOUT --sandbox off / ZERO_SANDBOX, the exec policy MUST be the default
|
||||
// enforcing one (workspace-confined, network denied). If this ever changes, the
|
||||
// container-trusted off-switch has leaked into the default.
|
||||
func TestExecSandboxPolicyDefaultUnchanged(t *testing.T) {
|
||||
policy := execSandboxPolicy(config.SandboxConfig{}, false)
|
||||
if policy.Mode != sandbox.ModeEnforce {
|
||||
t.Errorf("Mode = %q, want %q (default must not change)", policy.Mode, sandbox.ModeEnforce)
|
||||
}
|
||||
if policy.Network != sandbox.NetworkDeny {
|
||||
t.Errorf("Network = %q, want %q (default must not change)", policy.Network, sandbox.NetworkDeny)
|
||||
}
|
||||
if !policy.EnforceWorkspace {
|
||||
t.Error("EnforceWorkspace = false, want true (default must not change)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestExecSandboxPolicyOff: with the off-switch, all three confinement gates drop
|
||||
// so writes-anywhere and network both work inside an isolated container.
|
||||
func TestExecSandboxPolicyOff(t *testing.T) {
|
||||
policy := execSandboxPolicy(config.SandboxConfig{}, true)
|
||||
if policy.Mode != sandbox.ModeDisabled {
|
||||
t.Errorf("Mode = %q, want %q", policy.Mode, sandbox.ModeDisabled)
|
||||
}
|
||||
if policy.Network != sandbox.NetworkAllow {
|
||||
t.Errorf("Network = %q, want %q", policy.Network, sandbox.NetworkAllow)
|
||||
}
|
||||
if policy.EnforceWorkspace {
|
||||
t.Error("EnforceWorkspace = true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSandboxOff(t *testing.T) {
|
||||
cases := []struct {
|
||||
flag, env string
|
||||
want bool
|
||||
}{
|
||||
{"", "", false},
|
||||
{"off", "", true},
|
||||
{"disabled", "", true},
|
||||
{"none", "", true},
|
||||
{"0", "", true},
|
||||
{"OFF", "", true}, // case-insensitive
|
||||
{"enforce", "", false},
|
||||
{"", "off", true}, // env fallback
|
||||
{"", "disabled", true},
|
||||
{"", "1", false}, // unrecognized env value
|
||||
{"enforce", "off", false}, // flag wins over env
|
||||
{"off", "enforce", true}, // flag wins over env
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Setenv("ZERO_SANDBOX", c.env)
|
||||
if got := resolveSandboxOff(c.flag); got != c.want {
|
||||
t.Errorf("resolveSandboxOff(flag=%q, env=%q) = %v, want %v", c.flag, c.env, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveDeadline(t *testing.T) {
|
||||
cases := []struct {
|
||||
flag, env string
|
||||
want time.Duration
|
||||
}{
|
||||
{"", "", 0},
|
||||
{"900s", "", 15 * time.Minute},
|
||||
{"15m", "", 15 * time.Minute},
|
||||
{"", "300s", 5 * time.Minute}, // env fallback
|
||||
{"60s", "300s", time.Minute}, // flag wins over env
|
||||
{"bogus", "", 0}, // unparseable -> no deadline
|
||||
{"-5s", "", 0}, // non-positive -> no deadline
|
||||
{"0s", "", 0}, // zero -> no deadline
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Setenv("ZERO_DEADLINE", c.env)
|
||||
if got := resolveDeadline(c.flag); got != c.want {
|
||||
t.Errorf("resolveDeadline(flag=%q, env=%q) = %v, want %v", c.flag, c.env, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseExecArgsSandboxAndDeadline: both --flag value and --flag=value spellings
|
||||
// thread through to the options.
|
||||
func TestParseExecArgsSandboxAndDeadline(t *testing.T) {
|
||||
opts, _, err := parseExecArgs([]string{"--sandbox", "off", "--deadline", "900s", "do the task"})
|
||||
if err != nil {
|
||||
t.Fatalf("parseExecArgs: %v", err)
|
||||
}
|
||||
if opts.sandboxMode != "off" {
|
||||
t.Errorf("sandboxMode = %q, want off", opts.sandboxMode)
|
||||
}
|
||||
if opts.deadlineRaw != "900s" {
|
||||
t.Errorf("deadlineRaw = %q, want 900s", opts.deadlineRaw)
|
||||
}
|
||||
|
||||
opts2, _, err := parseExecArgs([]string{"--sandbox=disabled", "--deadline=15m", "x"})
|
||||
if err != nil {
|
||||
t.Fatalf("parseExecArgs inline: %v", err)
|
||||
}
|
||||
if opts2.sandboxMode != "disabled" || opts2.deadlineRaw != "15m" {
|
||||
t.Errorf("inline parse: sandboxMode=%q deadlineRaw=%q, want disabled/15m", opts2.sandboxMode, opts2.deadlineRaw)
|
||||
}
|
||||
}
|
||||
@@ -44,7 +44,7 @@ func TestRunAddDirDispatchForwardsGrantIntoExecScope(t *testing.T) {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Setenv("XDG_DATA_HOME", t.TempDir())
|
||||
cwd := t.TempDir()
|
||||
extra := tempDirOutsideDefaultTemp(t)
|
||||
extra := t.TempDir()
|
||||
target := filepath.Join(extra, "granted.txt")
|
||||
|
||||
exitCode, stderr := runExecAddDirWriteProbe(t, cwd, tc.args(extra), target)
|
||||
@@ -117,7 +117,7 @@ func runExecAddDirWriteProbe(t *testing.T, cwd string, args []string, target str
|
||||
// prove both the overwrite and the scoped enforcement it ships.
|
||||
func TestExecScopeReRegistrationSwapsCoreToolsByName(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
extra := tempDirOutsideDefaultTemp(t)
|
||||
extra := t.TempDir()
|
||||
inside := filepath.Join(extra, "inside.txt")
|
||||
|
||||
registry := newCoreRegistry(root)
|
||||
@@ -160,7 +160,7 @@ func TestExecScopeReRegistrationSwapsCoreToolsByName(t *testing.T) {
|
||||
}
|
||||
|
||||
// A path outside every scope root must still be denied after re-registration.
|
||||
outside := filepath.Join(tempDirOutsideDefaultTemp(t), "outside.txt")
|
||||
outside := filepath.Join(t.TempDir(), "outside.txt")
|
||||
deniedOutside := registry.RunWithOptions(context.Background(), "write_file", map[string]any{
|
||||
"path": outside,
|
||||
"content": "never",
|
||||
@@ -175,20 +175,3 @@ func TestExecScopeReRegistrationSwapsCoreToolsByName(t *testing.T) {
|
||||
t.Fatalf("expected outside file to remain absent, stat err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func tempDirOutsideDefaultTemp(t *testing.T) string {
|
||||
t.Helper()
|
||||
dir, err := os.MkdirTemp(".", ".zero-sandbox-outside-")
|
||||
if err != nil {
|
||||
t.Fatalf("MkdirTemp outside default temp: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.RemoveAll(dir) })
|
||||
abs, err := filepath.Abs(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("Abs(%q): %v", dir, err)
|
||||
}
|
||||
if resolved, err := filepath.EvalSymlinks(abs); err == nil {
|
||||
return resolved
|
||||
}
|
||||
return filepath.Clean(abs)
|
||||
}
|
||||
@@ -98,7 +98,7 @@ func runExecSpecDraft(run execSpecDraftRun) int {
|
||||
defer stopSignals()
|
||||
result, err := agent.Run(runCtx, run.prompt, run.provider, agent.Options{
|
||||
MaxTurns: run.resolved.MaxTurns,
|
||||
ContextWindow: resolveAgentContextWindow(runCtx, run.modelRegistry, run.resolved.Provider),
|
||||
ContextWindow: modelContextWindow(run.modelRegistry, run.resolved.Provider.Model),
|
||||
SessionID: draftSession.SessionID,
|
||||
SessionTitle: run.sessionTitle,
|
||||
ProviderName: run.resolved.Provider.Name,
|
||||
|
||||
@@ -465,7 +465,7 @@ func TestRunExecModeModelSurfacesDeprecationNoticeViaSharedPath(t *testing.T) {
|
||||
func TestRunExecListToolsAppliesModeBeforeListing(t *testing.T) {
|
||||
// applyExecMode now runs before tool-filter validation and the --list-tools
|
||||
// branch, so a --mode preset is expanded for --list-tools. Combining a mode
|
||||
// with --list-tools must still succeed without constructing a provider.
|
||||
// with --list-tools must still succeed and never resolve a provider.
|
||||
cwd := t.TempDir()
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
@@ -475,7 +475,7 @@ func TestRunExecListToolsAppliesModeBeforeListing(t *testing.T) {
|
||||
return cwd, nil
|
||||
},
|
||||
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
|
||||
return execResolvedConfig(), nil
|
||||
return config.ResolvedConfig{}, errors.New("provider should not be resolved for --list-tools")
|
||||
},
|
||||
})
|
||||
|
||||
@@ -505,49 +505,6 @@ func TestRunExecModeToolFilterReflectedInListTools(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveExecPermissionModeMember(t *testing.T) {
|
||||
cases := []struct {
|
||||
autonomy string
|
||||
want agent.PermissionMode
|
||||
}{
|
||||
{"", agent.PermissionModeAuto},
|
||||
{"low", agent.PermissionModeAuto},
|
||||
{"medium", agent.PermissionModeAuto},
|
||||
{"member", agent.PermissionModeMemberAuto}, // headless members: write + sandboxed shell
|
||||
{"high", agent.PermissionModeUnsafe},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got, err := resolveExecPermissionMode(execOptions{autonomy: c.autonomy})
|
||||
if err != nil {
|
||||
t.Fatalf("resolveExecPermissionMode(%q): %v", c.autonomy, err)
|
||||
}
|
||||
if got != c.want {
|
||||
t.Errorf("resolveExecPermissionMode(%q) = %q, want %q", c.autonomy, got, c.want)
|
||||
}
|
||||
}
|
||||
if _, err := resolveExecPermissionMode(execOptions{autonomy: "bogus"}); err == nil {
|
||||
t.Fatal("an unknown autonomy level must still be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
// A member-auto headless tool list must include the in-workspace mutators that
|
||||
// plain Auto hides, so a swarm member can actually build. Match the tool ENTRY
|
||||
// line (" write_file [") — a bare substring would false-match tool descriptions.
|
||||
func TestExecMemberAutoToolListIncludesMutators(t *testing.T) {
|
||||
registry := newCoreRegistry(t.TempDir())
|
||||
const writeEntry = "\n write_file ["
|
||||
|
||||
member := formatExecToolList(registry, execOptions{}, agent.PermissionModeMemberAuto)
|
||||
if !strings.Contains(member, writeEntry) {
|
||||
t.Fatalf("member-auto tool list must include write_file, got %q", member)
|
||||
}
|
||||
// Plain Auto must still hide it (unchanged behavior — this is the read-only gate).
|
||||
auto := formatExecToolList(registry, execOptions{}, agent.PermissionModeAuto)
|
||||
if strings.Contains(auto, writeEntry) {
|
||||
t.Fatalf("plain Auto must still hide write_file, got %q", auto)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunExecAcceptsLegacyModelProfileFlags(t *testing.T) {
|
||||
exitCode, stdout, stderr := runExecWithEcho(t, []string{
|
||||
"exec",
|
||||
|
||||
@@ -81,11 +81,6 @@ func resolveExecPermissionMode(options execOptions) (agent.PermissionMode, error
|
||||
switch strings.ToLower(strings.TrimSpace(options.autonomy)) {
|
||||
case "", "low", "medium":
|
||||
mode = agent.PermissionModeAuto
|
||||
case "member":
|
||||
// Internal autonomy for headless swarm/specialist members: Auto plus
|
||||
// advertised in-workspace mutators (see PermissionModeMemberAuto). The
|
||||
// swarm launcher sets this; it is not part of the public low|medium|high set.
|
||||
mode = agent.PermissionModeMemberAuto
|
||||
case "high":
|
||||
mode = agent.PermissionModeUnsafe
|
||||
default:
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
)
|
||||
|
||||
func TestCoreRegistryIncludesDefaultLocalControlTools(t *testing.T) {
|
||||
registry := newCoreRegistry(t.TempDir())
|
||||
for _, name := range []string{"browser_install", "browser_launch", "browser_connect", "browser_open", "browser_snapshot", "browser_click", "browser_type", "browser_press", "terminal_session", "capture_artifact"} {
|
||||
tool, ok := registry.Get(name)
|
||||
if !ok {
|
||||
t.Fatalf("%s not registered", name)
|
||||
}
|
||||
if tool.Safety().Permission != tools.PermissionPrompt {
|
||||
t.Fatalf("%s permission = %s, want prompt", name, tool.Safety().Permission)
|
||||
}
|
||||
}
|
||||
desktop, ok := registry.Get("desktop_action")
|
||||
if !ok {
|
||||
t.Fatal("desktop_action not registered")
|
||||
}
|
||||
if desktop.Safety().Permission != tools.PermissionDeny {
|
||||
t.Fatalf("desktop_action permission = %s, want deny", desktop.Safety().Permission)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoreRegistryHonorsExplicitLocalControlDisable(t *testing.T) {
|
||||
registry := newCoreRegistry(t.TempDir())
|
||||
var cfg config.LocalControlConfig
|
||||
if err := json.Unmarshal([]byte(`{"enabled":false}`), &cfg); err != nil {
|
||||
t.Fatalf("unmarshal local control config: %v", err)
|
||||
}
|
||||
registerLocalControlTools(registry, t.TempDir(), cfg)
|
||||
for _, name := range []string{"browser_install", "browser_launch", "browser_connect", "browser_open", "browser_snapshot", "browser_click", "browser_type", "browser_press", "desktop_action", "terminal_session", "capture_artifact"} {
|
||||
tool, ok := registry.Get(name)
|
||||
if !ok {
|
||||
t.Fatalf("%s not registered", name)
|
||||
}
|
||||
if tool.Safety().Permission != tools.PermissionDeny {
|
||||
t.Fatalf("%s permission = %s, want deny", name, tool.Safety().Permission)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunExecListToolsHonorsDisabledLocalControlConfig(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
cfg := execResolvedConfig()
|
||||
var localControl config.LocalControlConfig
|
||||
if err := json.Unmarshal([]byte(`{"enabled":false}`), &localControl); err != nil {
|
||||
t.Fatalf("unmarshal local control config: %v", err)
|
||||
}
|
||||
cfg.LocalControl = localControl
|
||||
|
||||
exitCode := runWithDeps([]string{"exec", "--list-tools", "-o", "json"}, &stdout, &stderr, appDeps{
|
||||
getwd: func() (string, error) {
|
||||
return t.TempDir(), nil
|
||||
},
|
||||
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
|
||||
return cfg, nil
|
||||
},
|
||||
})
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("exitCode = %d stdout=%s stderr=%s", exitCode, stdout.String(), stderr.String())
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Fatalf("stderr = %q, want empty", stderr.String())
|
||||
}
|
||||
var payload struct {
|
||||
Tools []struct {
|
||||
Name string `json:"name"`
|
||||
Permission string `json:"permission"`
|
||||
} `json:"tools"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(strings.TrimSpace(stdout.String())), &payload); err != nil {
|
||||
t.Fatalf("parse tools JSON: %v\n%s", err, stdout.String())
|
||||
}
|
||||
for _, tool := range payload.Tools {
|
||||
if tool.Name == "browser_open" {
|
||||
t.Fatalf("browser_open should be hidden when local control is disabled, got permission %s", tool.Permission)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterLocalControlToolsAppliesDefaults(t *testing.T) {
|
||||
workspaceRoot := t.TempDir()
|
||||
registry := newCoreRegistry(workspaceRoot)
|
||||
registerLocalControlTools(registry, workspaceRoot, config.LocalControlConfig{Enabled: true})
|
||||
for _, name := range []string{"browser_install", "browser_launch", "browser_connect", "browser_open", "browser_snapshot", "browser_click", "browser_type", "browser_press", "terminal_session", "capture_artifact"} {
|
||||
tool, ok := registry.Get(name)
|
||||
if !ok {
|
||||
t.Fatalf("%s not registered", name)
|
||||
}
|
||||
if tool.Safety().Permission != tools.PermissionPrompt {
|
||||
t.Fatalf("%s permission = %s, want prompt", name, tool.Safety().Permission)
|
||||
}
|
||||
}
|
||||
desktop, ok := registry.Get("desktop_action")
|
||||
if !ok {
|
||||
t.Fatal("desktop_action not registered")
|
||||
}
|
||||
if desktop.Safety().Permission != tools.PermissionDeny {
|
||||
t.Fatalf("desktop_action permission = %s, want deny without nested desktop opt-in", desktop.Safety().Permission)
|
||||
}
|
||||
}
|
||||
@@ -51,9 +51,7 @@ func runProvidersAdd(args []string, stdout io.Writer, stderr io.Writer, deps app
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
// Persist with the key moved into the encrypted credential store (capture flip);
|
||||
// the local profile keeps the key for the verification build below.
|
||||
cfg, err := config.UpsertProvider(configPath, config.SecureProviderProfile(profile, configPath), options.setActive)
|
||||
cfg, err := config.UpsertProvider(configPath, profile, options.setActive)
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
@@ -488,7 +486,7 @@ func validateProviderRuntimeReady(profile config.ProviderProfile) error {
|
||||
}
|
||||
|
||||
func providerProfileHasCredential(profile config.ProviderProfile) bool {
|
||||
return strings.TrimSpace(profile.APIKey) != "" || profile.APIKeyStored || strings.TrimSpace(profile.AuthHeaderValue) != ""
|
||||
return strings.TrimSpace(profile.APIKey) != "" || strings.TrimSpace(profile.AuthHeaderValue) != ""
|
||||
}
|
||||
|
||||
func providerKindForDescriptor(descriptor providercatalog.Descriptor) config.ProviderKind {
|
||||
|
||||
@@ -31,8 +31,8 @@ func TestRunSandboxCheckJSONDeniesOutOfWorkspaceWrite(t *testing.T) {
|
||||
deps, _ := sandboxCheckDeps(t)
|
||||
// An absolute path outside the workspace, portable across OSes: a Unix literal
|
||||
// like /etc/passwd is not absolute on Windows (it would be joined into the
|
||||
// workspace and allowed), so build one under a non-temp test dir instead.
|
||||
outside := filepath.Join(tempDirOutsideDefaultTemp(t), "outside.txt")
|
||||
// workspace and allowed), so build one under a separate temp dir instead.
|
||||
outside := filepath.Join(t.TempDir(), "outside.txt")
|
||||
var stdout, stderr bytes.Buffer
|
||||
exitCode := runWithDeps([]string{"sandbox", "check", "write_file", "--side-effect", "write", "--path", outside, "--json"}, &stdout, &stderr, deps)
|
||||
if exitCode != exitSuccess {
|
||||
|
||||
@@ -510,94 +510,15 @@ func TestRunSandboxPolicyJSONGoldenIncludesManagerBaselineFields(t *testing.T) {
|
||||
got := stdout.String()
|
||||
got = replacePathToken(got, workspace, "$WORKSPACE")
|
||||
got = replacePathToken(got, store.FilePath(), "$GRANTS")
|
||||
gotBytes := normalizeSandboxPolicyGoldenTempRoots(t, []byte(got), workspace)
|
||||
wantBytes, err := os.ReadFile(filepath.Join("testdata", "sandbox_policy_windows_unavailable.golden.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("read golden: %v", err)
|
||||
}
|
||||
if !jsonValuesEqual(t, wantBytes, gotBytes) {
|
||||
t.Fatalf("policy JSON golden mismatch\nwant:\n%s\ngot:\n%s", string(wantBytes), string(gotBytes))
|
||||
if !jsonValuesEqual(t, wantBytes, []byte(got)) {
|
||||
t.Fatalf("policy JSON golden mismatch\nwant:\n%s\ngot:\n%s", string(wantBytes), got)
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeSandboxPolicyGoldenTempRoots(t *testing.T, gotBytes []byte, workspace string) []byte {
|
||||
t.Helper()
|
||||
scope, err := sandbox.NewScope(workspace, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("NewScope(%q): %v", workspace, err)
|
||||
}
|
||||
roots := scope.Roots()
|
||||
if len(roots) <= 1 {
|
||||
return gotBytes
|
||||
}
|
||||
tempRoots := map[string]struct{}{}
|
||||
for _, root := range roots[1:] {
|
||||
tempRoots[root] = struct{}{}
|
||||
}
|
||||
var value any
|
||||
if err := json.Unmarshal(gotBytes, &value); err != nil {
|
||||
t.Fatalf("decode got JSON for normalization: %v\n%s", err, string(gotBytes))
|
||||
}
|
||||
plan, _ := value.(map[string]any)["plan"].(map[string]any)
|
||||
profile, _ := plan["permissionProfile"].(map[string]any)
|
||||
fileSystem, _ := profile["fileSystem"].(map[string]any)
|
||||
fileSystem["readRoots"] = filterJSONStringRoots(fileSystem["readRoots"], tempRoots)
|
||||
fileSystem["writeRoots"] = filterJSONWriteRoots(fileSystem["writeRoots"], tempRoots)
|
||||
normalized, err := json.MarshalIndent(value, "", " ")
|
||||
if err != nil {
|
||||
t.Fatalf("encode normalized policy JSON: %v", err)
|
||||
}
|
||||
return append(normalized, '\n')
|
||||
}
|
||||
|
||||
func filterJSONStringRoots(value any, excluded map[string]struct{}) any {
|
||||
roots, ok := value.([]any)
|
||||
if !ok {
|
||||
return value
|
||||
}
|
||||
out := make([]any, 0, len(roots))
|
||||
for _, root := range roots {
|
||||
text, ok := root.(string)
|
||||
if !ok {
|
||||
out = append(out, root)
|
||||
continue
|
||||
}
|
||||
if _, skip := excluded[text]; !skip {
|
||||
out = append(out, root)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func filterJSONWriteRoots(value any, excluded map[string]struct{}) any {
|
||||
roots, ok := value.([]any)
|
||||
if !ok {
|
||||
return value
|
||||
}
|
||||
out := make([]any, 0, len(roots))
|
||||
for _, root := range roots {
|
||||
entry, ok := root.(map[string]any)
|
||||
if !ok {
|
||||
out = append(out, root)
|
||||
continue
|
||||
}
|
||||
text, _ := entry["root"].(string)
|
||||
if _, skip := excluded[text]; !skip {
|
||||
out = append(out, root)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func containsString(values []string, want string) bool {
|
||||
for _, value := range values {
|
||||
if value == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func jsonValuesEqual(t *testing.T, wantBytes []byte, gotBytes []byte) bool {
|
||||
t.Helper()
|
||||
var wantValue any
|
||||
@@ -608,30 +529,9 @@ func jsonValuesEqual(t *testing.T, wantBytes []byte, gotBytes []byte) bool {
|
||||
if err := json.Unmarshal(gotBytes, &gotValue); err != nil {
|
||||
t.Fatalf("decode got JSON: %v\n%s", err, string(gotBytes))
|
||||
}
|
||||
normalizePortableJSONRootString(&wantValue)
|
||||
normalizePortableJSONRootString(&gotValue)
|
||||
return reflect.DeepEqual(wantValue, gotValue)
|
||||
}
|
||||
|
||||
func normalizePortableJSONRootString(value *any) {
|
||||
switch current := (*value).(type) {
|
||||
case string:
|
||||
if current == `\` {
|
||||
*value = "/"
|
||||
}
|
||||
case []any:
|
||||
for index := range current {
|
||||
normalizePortableJSONRootString(¤t[index])
|
||||
}
|
||||
case map[string]any:
|
||||
for key := range current {
|
||||
entry := current[key]
|
||||
normalizePortableJSONRootString(&entry)
|
||||
current[key] = entry
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func replacePathToken(value string, path string, token string) string {
|
||||
replace := func(value string, path string) string {
|
||||
value = strings.ReplaceAll(value, path, token)
|
||||
@@ -754,7 +654,7 @@ func TestEffectiveSandboxPolicyShowsWriteRootsError(t *testing.T) {
|
||||
|
||||
func TestRunSandboxPolicyEffectiveListsConfiguredWriteRoots(t *testing.T) {
|
||||
store := newSandboxTestStore(t)
|
||||
extra := tempDirOutsideDefaultTemp(t)
|
||||
extra := t.TempDir()
|
||||
resolvedExtra, err := filepath.EvalSymlinks(extra)
|
||||
if err != nil {
|
||||
t.Fatalf("EvalSymlinks(%q) returned error: %v", extra, err)
|
||||
@@ -793,11 +693,11 @@ func TestRunSandboxPolicyEffectiveListsConfiguredWriteRoots(t *testing.T) {
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode effective JSON: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if len(payload.WriteRoots) < 2 {
|
||||
t.Fatalf("writeRoots = %#v, want workspace root, extra root, and default temp roots", payload.WriteRoots)
|
||||
if len(payload.WriteRoots) != 2 {
|
||||
t.Fatalf("writeRoots = %#v, want workspace root + extra root", payload.WriteRoots)
|
||||
}
|
||||
if !containsString(payload.WriteRoots, resolvedExtra) {
|
||||
t.Fatalf("writeRoots = %#v, want configured extra root %q", payload.WriteRoots, resolvedExtra)
|
||||
if payload.WriteRoots[1] != resolvedExtra {
|
||||
t.Fatalf("writeRoots[1] = %q, want %q", payload.WriteRoots[1], resolvedExtra)
|
||||
}
|
||||
if strings.Contains(stdout.String(), "writeRootsError") {
|
||||
t.Fatalf("unexpected writeRootsError key for valid roots:\n%s", stdout.String())
|
||||
@@ -845,11 +745,8 @@ func TestRunSandboxPolicyEffectiveWriteRootsFailSoft(t *testing.T) {
|
||||
if !strings.Contains(payload.WriteRootsError, missing) {
|
||||
t.Fatalf("writeRootsError = %q, want it to name the stale root %q", payload.WriteRootsError, missing)
|
||||
}
|
||||
if len(payload.WriteRoots) == 0 {
|
||||
t.Fatalf("writeRoots = %#v, want workspace/default-temp fallback", payload.WriteRoots)
|
||||
}
|
||||
if containsString(payload.WriteRoots, missing) {
|
||||
t.Fatalf("writeRoots = %#v, must not include stale root %q", payload.WriteRoots, missing)
|
||||
if len(payload.WriteRoots) != 1 {
|
||||
t.Fatalf("writeRoots = %#v, want workspace-only fallback", payload.WriteRoots)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
-47
@@ -263,9 +263,7 @@ func saveSetupProvider(deps appDeps, selection tui.SetupSelection, options setup
|
||||
if err != nil {
|
||||
return tui.SetupResult{}, err
|
||||
}
|
||||
// Persist with the key moved into the encrypted credential store (capture flip);
|
||||
// the returned profile keeps the key for this run's immediate use.
|
||||
if _, err := config.UpsertProvider(configPath, config.SecureProviderProfile(profile, configPath), true); err != nil {
|
||||
if _, err := config.UpsertProvider(configPath, profile, true); err != nil {
|
||||
return tui.SetupResult{}, err
|
||||
}
|
||||
return tui.SetupResult{ConfigPath: configPath, Provider: profile}, nil
|
||||
@@ -383,50 +381,6 @@ func firstUsableProvider(providers []config.ProviderProfile) (config.ProviderPro
|
||||
return config.ProviderProfile{}, false
|
||||
}
|
||||
|
||||
// usableSavedProviders filters configured providers to those the user can actually
|
||||
// use: an inline/stored/env key, an auth header, a stored OAuth login, or a no-auth
|
||||
// local provider. This keeps /model from listing providers that are merely present
|
||||
// in config.json but never authenticated (e.g. a default openai entry with no key).
|
||||
func usableSavedProviders(providers []config.ProviderProfile) []config.ProviderProfile {
|
||||
logins := oauthLoggedInProviders()
|
||||
store, storeErr := config.ProviderKeyStore()
|
||||
usable := make([]config.ProviderProfile, 0, len(providers))
|
||||
for _, profile := range providers {
|
||||
if !config.HasProviderProfile(profile) {
|
||||
continue
|
||||
}
|
||||
// A provider whose only credential is the APIKeyStored marker counts as usable
|
||||
// only when the key is actually retrievable — the keyring/file entry may have
|
||||
// been deleted, leaving a stale marker that would otherwise list it in /model.
|
||||
if profile.APIKeyStored && strings.TrimSpace(profile.APIKey) == "" {
|
||||
if storeErr == nil {
|
||||
if key, ok, err := store.Get(profile.Name); err == nil && ok && strings.TrimSpace(key) != "" {
|
||||
usable = append(usable, profile)
|
||||
continue
|
||||
}
|
||||
}
|
||||
// Marker present but key missing/unreadable: fall through and judge the
|
||||
// profile on its other signals (env var, OAuth, local) with the marker off.
|
||||
withoutMarker := profile
|
||||
withoutMarker.APIKeyStored = false
|
||||
if _, missing := setupMissingCredentialEnv(withoutMarker); !missing {
|
||||
usable = append(usable, profile)
|
||||
} else if providerHasOAuthLogin(profile, logins) {
|
||||
usable = append(usable, profile)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if _, missing := setupMissingCredentialEnv(profile); !missing {
|
||||
usable = append(usable, profile)
|
||||
continue
|
||||
}
|
||||
if providerHasOAuthLogin(profile, logins) {
|
||||
usable = append(usable, profile)
|
||||
}
|
||||
}
|
||||
return usable
|
||||
}
|
||||
|
||||
// providerProfileIsLocal reports whether a provider points at a local endpoint
|
||||
// (a loopback URL or a catalog entry flagged Local), so the fallback prefers a
|
||||
// remote keyed provider that is more likely reachable.
|
||||
|
||||
@@ -103,9 +103,6 @@ func TestSetupProviderOptionsUseRuntimeSupportedCatalog(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSaveSetupProviderStoresPastedAPIKey(t *testing.T) {
|
||||
// Use the encrypted-file backend in the temp config dir so the test never
|
||||
// touches the real OS keychain.
|
||||
t.Setenv("ZERO_CRED_STORAGE", "encrypted-file")
|
||||
configPath := filepath.Join(t.TempDir(), "zero", "config.json")
|
||||
|
||||
result, err := saveSetupProvider(appDeps{
|
||||
@@ -135,20 +132,8 @@ func TestSaveSetupProviderStoresPastedAPIKey(t *testing.T) {
|
||||
if len(cfg.Providers) != 1 {
|
||||
t.Fatalf("Providers = %#v, want one provider", cfg.Providers)
|
||||
}
|
||||
// The capture flip: config.json must NOT hold the cleartext key — it carries the
|
||||
// APIKeyStored marker, and the secret lives in the co-located credential store.
|
||||
if cfg.Providers[0].APIKey != "" || cfg.Providers[0].APIKeyEnv != "" {
|
||||
t.Fatalf("config must not persist the key: APIKey %q APIKeyEnv %q", cfg.Providers[0].APIKey, cfg.Providers[0].APIKeyEnv)
|
||||
}
|
||||
if !cfg.Providers[0].APIKeyStored {
|
||||
t.Fatal("expected APIKeyStored marker in config")
|
||||
}
|
||||
store, err := config.ProviderKeyStoreAt(filepath.Dir(configPath))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if key, ok, _ := store.Get("ollama-cloud"); !ok || key != "sk-pasted-secret" {
|
||||
t.Fatalf("stored key = %q,%v; want sk-pasted-secret in the credential store", key, ok)
|
||||
if cfg.Providers[0].APIKey != "sk-pasted-secret" || cfg.Providers[0].APIKeyEnv != "" {
|
||||
t.Fatalf("stored provider credentials = APIKey %q APIKeyEnv %q, want pasted key only", cfg.Providers[0].APIKey, cfg.Providers[0].APIKeyEnv)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -34,7 +34,6 @@
|
||||
"fileSystem": {
|
||||
"kind": "restricted",
|
||||
"readRoots": [
|
||||
"/",
|
||||
"$WORKSPACE"
|
||||
],
|
||||
"writeRoots": [
|
||||
|
||||
@@ -1,177 +0,0 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/credstore"
|
||||
)
|
||||
|
||||
// ProviderKeyStoreAt opens the encrypted credential store whose file backend lives
|
||||
// in dir. The backend resolves keyring-first, then encrypted-file, with a plaintext
|
||||
// opt-out via ZERO_CRED_STORAGE. The dir parameter exists so tests can point the file
|
||||
// backend at a temp directory; production always uses the user config directory
|
||||
// (ProviderKeyStore) because provider API keys are user-scoped by design — they are
|
||||
// only ever captured under the user config, never project config (a cloned repo must
|
||||
// not carry keys), so runtime lookups deliberately use the user store regardless of
|
||||
// where a provider profile was resolved from.
|
||||
func ProviderKeyStoreAt(dir string) (*credstore.Store, error) {
|
||||
return credstore.New(credstore.Options{Dir: dir})
|
||||
}
|
||||
|
||||
// ProviderKeyStore opens the credential store beside the default user config.
|
||||
func ProviderKeyStore() (*credstore.Store, error) {
|
||||
configPath, err := DefaultUserConfigPath()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ProviderKeyStoreAt(filepath.Dir(configPath))
|
||||
}
|
||||
|
||||
// APIKeyGetter is the read surface of the credential store; *credstore.Store
|
||||
// satisfies it. Kept here so callers can inject a fake in tests.
|
||||
type APIKeyGetter interface {
|
||||
Get(provider string) (string, bool, error)
|
||||
}
|
||||
|
||||
// APIKeySetter is the write surface of the credential store.
|
||||
type APIKeySetter interface {
|
||||
Set(provider, key string) error
|
||||
}
|
||||
|
||||
// SecureProviderProfile moves an inline APIKey on the profile into the credential
|
||||
// store co-located with configPath, returning a profile with APIKeyStored set and
|
||||
// APIKey cleared so the secret is never written to config.json. On any store error
|
||||
// (e.g. no keyring) it returns the profile unchanged — the inline key persists and
|
||||
// the startup migration retries later — so capturing a provider never fails on a
|
||||
// flaky credential backend.
|
||||
func SecureProviderProfile(profile ProviderProfile, configPath string) ProviderProfile {
|
||||
if strings.TrimSpace(profile.APIKey) == "" || strings.TrimSpace(profile.Name) == "" {
|
||||
return profile
|
||||
}
|
||||
store, err := ProviderKeyStoreAt(filepath.Dir(configPath))
|
||||
if err != nil {
|
||||
return profile
|
||||
}
|
||||
if err := store.Set(profile.Name, profile.APIKey); err != nil {
|
||||
return profile
|
||||
}
|
||||
secured := profile
|
||||
secured.APIKey = ""
|
||||
secured.APIKeyStored = true
|
||||
return secured
|
||||
}
|
||||
|
||||
// ForgetProviderKey removes a provider's stored API key from the credential store,
|
||||
// reporting whether one existed. Used by the lifecycle "remove key" / auth logout.
|
||||
func ForgetProviderKey(provider string) (bool, error) {
|
||||
store, err := ProviderKeyStore()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return store.Delete(provider)
|
||||
}
|
||||
|
||||
// ClearProviderKeyStored unsets the APIKeyStored marker for a provider in the
|
||||
// config at path, so credential checks no longer claim a stored key after one is
|
||||
// removed. No-op when path/provider is empty, the config is absent, or the marker
|
||||
// is already unset.
|
||||
func ClearProviderKeyStored(path, provider string) (bool, error) {
|
||||
path = strings.TrimSpace(path)
|
||||
provider = strings.TrimSpace(provider)
|
||||
if path == "" || provider == "" {
|
||||
return false, nil
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return false, nil
|
||||
}
|
||||
return false, fmt.Errorf("read config %s: %w", path, err)
|
||||
}
|
||||
var cfg FileConfig
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
return false, fmt.Errorf("invalid config JSON %s: %w", path, err)
|
||||
}
|
||||
changed := false
|
||||
for index := range cfg.Providers {
|
||||
if strings.EqualFold(strings.TrimSpace(cfg.Providers[index].Name), provider) && cfg.Providers[index].APIKeyStored {
|
||||
cfg.Providers[index].APIKeyStored = false
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if !changed {
|
||||
return false, nil
|
||||
}
|
||||
return true, writeConfigFile(path, cfg)
|
||||
}
|
||||
|
||||
// MigratePlaintextProviderKeys moves any inline plaintext API key in the config at
|
||||
// path into the credential store, marking the profile APIKeyStored and stripping
|
||||
// the inline secret — but ONLY after the store write succeeds, so a failed Set
|
||||
// leaves the plaintext key in place and never strands a credential. Returns how
|
||||
// many keys were migrated; a no-op (0, nil) when path is empty/absent or nothing
|
||||
// needs migrating. Safe to run on every startup (idempotent).
|
||||
func MigratePlaintextProviderKeys(path string, store APIKeySetter) (int, error) {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" || store == nil {
|
||||
return 0, nil
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return 0, nil
|
||||
}
|
||||
return 0, fmt.Errorf("read config %s: %w", path, err)
|
||||
}
|
||||
var cfg FileConfig
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
return 0, fmt.Errorf("invalid config JSON %s: %w", path, err)
|
||||
}
|
||||
migrated := 0
|
||||
for index := range cfg.Providers {
|
||||
profile := &cfg.Providers[index]
|
||||
key := strings.TrimSpace(profile.APIKey)
|
||||
if key == "" || strings.TrimSpace(profile.Name) == "" {
|
||||
continue
|
||||
}
|
||||
if err := store.Set(profile.Name, key); err != nil {
|
||||
// Leave the plaintext key untouched; a failed Set must not strand it.
|
||||
continue
|
||||
}
|
||||
profile.APIKey = ""
|
||||
profile.APIKeyStored = true
|
||||
migrated++
|
||||
}
|
||||
if migrated == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
if err := writeConfigFile(path, cfg); err != nil {
|
||||
return migrated, fmt.Errorf("rewrite config after migration %s: %w", path, err)
|
||||
}
|
||||
return migrated, nil
|
||||
}
|
||||
|
||||
// ApplyStoredAPIKey fills profile.APIKey from the credential store when it is not
|
||||
// already resolved — an inline config key or a resolved APIKeyEnv always wins, so
|
||||
// this only supplies a key for providers whose secret lives in the store. A nil
|
||||
// store, a miss, or a store error leaves the profile unchanged.
|
||||
func ApplyStoredAPIKey(profile ProviderProfile, store APIKeyGetter) ProviderProfile {
|
||||
// Only load for profiles that opted into stored-key auth (APIKeyStored). Without
|
||||
// this gate a stale keyring/file entry could silently reactivate credentials for a
|
||||
// profile that no longer uses the store.
|
||||
if store == nil || !profile.APIKeyStored || strings.TrimSpace(profile.APIKey) != "" {
|
||||
return profile
|
||||
}
|
||||
name := strings.TrimSpace(profile.Name)
|
||||
if name == "" {
|
||||
return profile
|
||||
}
|
||||
if key, ok, err := store.Get(name); err == nil && ok && strings.TrimSpace(key) != "" {
|
||||
profile.APIKey = key
|
||||
}
|
||||
return profile
|
||||
}
|
||||
@@ -1,174 +0,0 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type fakeKeyGetter struct {
|
||||
keys map[string]string
|
||||
err error
|
||||
}
|
||||
|
||||
func (f fakeKeyGetter) Get(provider string) (string, bool, error) {
|
||||
if f.err != nil {
|
||||
return "", false, f.err
|
||||
}
|
||||
v, ok := f.keys[provider]
|
||||
return v, ok, nil
|
||||
}
|
||||
|
||||
// fakeKeySetter records Set calls; setErr (when non-nil) makes Set fail.
|
||||
type fakeKeySetter struct {
|
||||
keys map[string]string
|
||||
setErr error
|
||||
}
|
||||
|
||||
func (f *fakeKeySetter) Set(provider, key string) error {
|
||||
if f.setErr != nil {
|
||||
return f.setErr
|
||||
}
|
||||
f.keys[provider] = key
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestApplyStoredAPIKey(t *testing.T) {
|
||||
store := fakeKeyGetter{keys: map[string]string{"openai": "sk-stored"}}
|
||||
|
||||
// Stored marker + empty key => filled from the store.
|
||||
got := ApplyStoredAPIKey(ProviderProfile{Name: "openai", APIKeyStored: true}, store)
|
||||
if got.APIKey != "sk-stored" {
|
||||
t.Fatalf("expected stored key to fill empty APIKey, got %q", got.APIKey)
|
||||
}
|
||||
|
||||
// No APIKeyStored marker => store is NOT consulted (don't reactivate a stale key).
|
||||
got = ApplyStoredAPIKey(ProviderProfile{Name: "openai"}, store)
|
||||
if got.APIKey != "" {
|
||||
t.Fatalf("expected no load without APIKeyStored, got %q", got.APIKey)
|
||||
}
|
||||
|
||||
// Inline key present => store is NOT consulted (inline wins).
|
||||
got = ApplyStoredAPIKey(ProviderProfile{Name: "openai", APIKeyStored: true, APIKey: "sk-inline"}, store)
|
||||
if got.APIKey != "sk-inline" {
|
||||
t.Fatalf("inline key must win, got %q", got.APIKey)
|
||||
}
|
||||
|
||||
// Marker set but no stored key for this provider => unchanged (empty).
|
||||
got = ApplyStoredAPIKey(ProviderProfile{Name: "anthropic", APIKeyStored: true}, store)
|
||||
if got.APIKey != "" {
|
||||
t.Fatalf("expected no key for unstored provider, got %q", got.APIKey)
|
||||
}
|
||||
|
||||
// Nil store => unchanged.
|
||||
got = ApplyStoredAPIKey(ProviderProfile{Name: "openai", APIKeyStored: true}, nil)
|
||||
if got.APIKey != "" {
|
||||
t.Fatalf("nil store must leave profile unchanged, got %q", got.APIKey)
|
||||
}
|
||||
|
||||
// Empty name => unchanged (don't query the store).
|
||||
got = ApplyStoredAPIKey(ProviderProfile{APIKeyStored: true}, store)
|
||||
if got.APIKey != "" {
|
||||
t.Fatalf("empty name must not be filled, got %q", got.APIKey)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigratePlaintextProviderKeys(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config.json")
|
||||
cfgJSON := `{"activeProvider":"openai","providers":[` +
|
||||
`{"name":"openai","apiKey":"sk-PLAINTEXT","model":"gpt"},` +
|
||||
`{"name":"local","baseURL":"http://localhost"},` +
|
||||
`{"name":"acme","apiKeyEnv":"ACME_KEY"}]}`
|
||||
if err := os.WriteFile(path, []byte(cfgJSON), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store := &fakeKeySetter{keys: map[string]string{}}
|
||||
|
||||
n, err := MigratePlaintextProviderKeys(path, store)
|
||||
if err != nil || n != 1 {
|
||||
t.Fatalf("migrate = %d,%v; want 1,nil", n, err)
|
||||
}
|
||||
if store.keys["openai"] != "sk-PLAINTEXT" {
|
||||
t.Fatalf("key not moved to store: %v", store.keys)
|
||||
}
|
||||
raw, _ := os.ReadFile(path)
|
||||
if strings.Contains(string(raw), "sk-PLAINTEXT") {
|
||||
t.Fatalf("plaintext key still in config.json:\n%s", raw)
|
||||
}
|
||||
if !strings.Contains(string(raw), "apiKeyStored") {
|
||||
t.Fatalf("apiKeyStored marker not written:\n%s", raw)
|
||||
}
|
||||
// Idempotent: a second run migrates nothing.
|
||||
if n2, _ := MigratePlaintextProviderKeys(path, store); n2 != 0 {
|
||||
t.Fatalf("second migrate = %d, want 0", n2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateLeavesKeyWhenStoreSetFails(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config.json")
|
||||
if err := os.WriteFile(path, []byte(`{"providers":[{"name":"openai","apiKey":"sk-KEEP"}]}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store := &fakeKeySetter{keys: map[string]string{}, setErr: errors.New("keychain locked")}
|
||||
n, err := MigratePlaintextProviderKeys(path, store)
|
||||
if err != nil || n != 0 {
|
||||
t.Fatalf("migrate with failing store = %d,%v; want 0,nil", n, err)
|
||||
}
|
||||
raw, _ := os.ReadFile(path)
|
||||
if !strings.Contains(string(raw), "sk-KEEP") {
|
||||
t.Fatalf("a failed Set must not strand the key; config.json:\n%s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClearProviderKeyStored(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config.json")
|
||||
if err := os.WriteFile(path, []byte(`{"providers":[{"name":"openai","apiKeyStored":true},{"name":"other","apiKeyStored":true}]}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cleared, err := ClearProviderKeyStored(path, "openai")
|
||||
if err != nil || !cleared {
|
||||
t.Fatalf("clear = %v,%v; want true,nil", cleared, err)
|
||||
}
|
||||
raw, _ := os.ReadFile(path)
|
||||
// openai's marker is gone; other's remains.
|
||||
var cfg FileConfig
|
||||
if err := json.Unmarshal(raw, &cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, p := range cfg.Providers {
|
||||
if p.Name == "openai" && p.APIKeyStored {
|
||||
t.Fatal("openai marker should be cleared")
|
||||
}
|
||||
if p.Name == "other" && !p.APIKeyStored {
|
||||
t.Fatal("other marker should be untouched")
|
||||
}
|
||||
}
|
||||
// Idempotent: clearing again reports no change.
|
||||
if cleared, _ := ClearProviderKeyStored(path, "openai"); cleared {
|
||||
t.Fatal("second clear should report no change")
|
||||
}
|
||||
// Unknown provider: no change.
|
||||
if cleared, _ := ClearProviderKeyStored(path, "nope"); cleared {
|
||||
t.Fatal("unknown provider should report no change")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderProfileAPIKeyStoredRoundTrips(t *testing.T) {
|
||||
// The apiKeyStored marker survives JSON decode (custom UnmarshalJSON).
|
||||
var p ProviderProfile
|
||||
if err := p.UnmarshalJSON([]byte(`{"name":"openai","apiKeyStored":true}`)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !p.APIKeyStored {
|
||||
t.Fatal("expected apiKeyStored=true to decode")
|
||||
}
|
||||
if p.APIKey != "" {
|
||||
t.Fatalf("no inline key expected, got %q", p.APIKey)
|
||||
}
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestResolveLocalControlFromUserConfig(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "config.json")
|
||||
if err := os.WriteFile(path, []byte(`{
|
||||
"localControl": {
|
||||
"enabled": true,
|
||||
"browser": {"driver": "agent-browser"},
|
||||
"desktop": {"enabled": true},
|
||||
"terminal": {"enabled": false}
|
||||
}
|
||||
}`), 0o600); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
|
||||
resolved, err := Resolve(ResolveOptions{UserConfigPath: path})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve returned error: %v", err)
|
||||
}
|
||||
if !resolved.LocalControl.Enabled {
|
||||
t.Fatal("localControl.enabled = false, want true")
|
||||
}
|
||||
if !resolved.LocalControl.BrowserEnabled() {
|
||||
t.Fatal("BrowserEnabled = false, want true by default under localControl.enabled")
|
||||
}
|
||||
if !resolved.LocalControl.DesktopEnabled() {
|
||||
t.Fatal("DesktopEnabled = false, want explicit true")
|
||||
}
|
||||
if resolved.LocalControl.TerminalEnabled() {
|
||||
t.Fatal("TerminalEnabled = true, want explicit false")
|
||||
}
|
||||
if resolved.LocalControl.Browser.Driver != "agent-browser" {
|
||||
t.Fatalf("browser driver = %q, want agent-browser", resolved.LocalControl.Browser.Driver)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveLocalControlIgnoresProjectOptIn(t *testing.T) {
|
||||
projectPath := filepath.Join(t.TempDir(), "project.json")
|
||||
if err := os.WriteFile(projectPath, []byte(`{"localControl":{"enabled":true,"desktop":{"enabled":true}}}`), 0o600); err != nil {
|
||||
t.Fatalf("write project config: %v", err)
|
||||
}
|
||||
|
||||
resolved, err := Resolve(ResolveOptions{ProjectConfigPath: projectPath})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve returned error: %v", err)
|
||||
}
|
||||
if resolved.LocalControl.Enabled || resolved.LocalControl.DesktopEnabled() {
|
||||
t.Fatalf("project config enabled gated local control: %#v", resolved.LocalControl)
|
||||
}
|
||||
if !resolved.LocalControl.BrowserEnabled() || !resolved.LocalControl.TerminalEnabled() {
|
||||
t.Fatalf("browser/terminal should remain available by default: %#v", resolved.LocalControl)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalControlDefaultsBrowserAndTerminalOn(t *testing.T) {
|
||||
resolved, err := Resolve(ResolveOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve returned error: %v", err)
|
||||
}
|
||||
if !resolved.LocalControl.BrowserEnabled() {
|
||||
t.Fatal("BrowserEnabled = false, want default true")
|
||||
}
|
||||
if !resolved.LocalControl.TerminalEnabled() {
|
||||
t.Fatal("TerminalEnabled = false, want default true")
|
||||
}
|
||||
if resolved.LocalControl.DesktopEnabled() {
|
||||
t.Fatal("DesktopEnabled = true, want explicit opt-in")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalControlExplicitGlobalDisable(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "config.json")
|
||||
if err := os.WriteFile(path, []byte(`{"localControl":{"enabled":false}}`), 0o600); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
resolved, err := Resolve(ResolveOptions{UserConfigPath: path})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve returned error: %v", err)
|
||||
}
|
||||
if resolved.LocalControl.BrowserEnabled() || resolved.LocalControl.TerminalEnabled() || resolved.LocalControl.DesktopEnabled() {
|
||||
t.Fatalf("local control not disabled globally: %#v", resolved.LocalControl)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalControlExplicitFalseSurvivesConfigRewrite(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "config.json")
|
||||
if err := os.WriteFile(path, []byte(`{
|
||||
"activeProvider": "openai",
|
||||
"providers": [{"name": "openai", "provider_kind": "openai", "model": "gpt-4.1"}],
|
||||
"localControl": {
|
||||
"enabled": true,
|
||||
"browser": {"enabled": false},
|
||||
"terminal": {"enabled": false}
|
||||
}
|
||||
}`), 0o600); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
|
||||
if _, err := SetFavoriteModels(path, []string{"gpt-4.1"}); err != nil {
|
||||
t.Fatalf("SetFavoriteModels returned error: %v", err)
|
||||
}
|
||||
persisted := readConfigFixture(t, path)
|
||||
if !persisted.LocalControl.enabledSet || !persisted.LocalControl.Enabled {
|
||||
t.Fatalf("localControl enabled not preserved: %#v", persisted.LocalControl)
|
||||
}
|
||||
if !persisted.LocalControl.Browser.enabledSet || persisted.LocalControl.Browser.Enabled {
|
||||
t.Fatalf("browser explicit false not preserved: %#v", persisted.LocalControl.Browser)
|
||||
}
|
||||
if !persisted.LocalControl.Terminal.enabledSet || persisted.LocalControl.Terminal.Enabled {
|
||||
t.Fatalf("terminal explicit false not preserved: %#v", persisted.LocalControl.Terminal)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmptyLocalControlIsOmittedFromConfigJSON(t *testing.T) {
|
||||
data, err := json.Marshal(FileConfig{})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal config: %v", err)
|
||||
}
|
||||
if strings.Contains(string(data), "localControl") {
|
||||
t.Fatalf("empty localControl should be omitted, got %s", string(data))
|
||||
}
|
||||
}
|
||||
@@ -109,7 +109,6 @@ func Resolve(options ResolveOptions) (ResolvedConfig, error) {
|
||||
Tools: cfg.Tools,
|
||||
Swarm: cfg.Swarm,
|
||||
Preferences: cfg.Preferences,
|
||||
LocalControl: cfg.LocalControl,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -183,10 +182,6 @@ func mergeConfig(dst *FileConfig, src FileConfig) {
|
||||
if src.Preferences.FavoriteModels != nil {
|
||||
dst.Preferences.FavoriteModels = normalizeFavoriteModels(src.Preferences.FavoriteModels)
|
||||
}
|
||||
if src.Preferences.Recaps != nil {
|
||||
dst.Preferences.Recaps = src.Preferences.Recaps
|
||||
}
|
||||
mergeLocalControlConfig(&dst.LocalControl, src.LocalControl)
|
||||
}
|
||||
|
||||
func mergeProjectConfig(dst *FileConfig, src FileConfig) error {
|
||||
@@ -230,9 +225,6 @@ func mergeProjectConfig(dst *FileConfig, src FileConfig) error {
|
||||
if src.Swarm.MaxTeamSize != 0 {
|
||||
dst.Swarm.MaxTeamSize = src.Swarm.MaxTeamSize
|
||||
}
|
||||
// Local control is intentionally user-config/override only. A cloned project
|
||||
// must not be able to make browser, desktop, or terminal automation tools
|
||||
// appear in the model's tool surface.
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -563,7 +555,6 @@ func applyOverrides(cfg *FileConfig, overrides Overrides) {
|
||||
cfg.Tools.DeferThreshold = overrides.Tools.DeferThreshold
|
||||
cfg.Tools.deferThresholdSet = true
|
||||
}
|
||||
mergeLocalControlConfig(&cfg.LocalControl, overrides.LocalControl)
|
||||
for _, provider := range overrides.Providers {
|
||||
mergeProvider(cfg, provider)
|
||||
}
|
||||
@@ -573,32 +564,6 @@ func applyOverrides(cfg *FileConfig, overrides Overrides) {
|
||||
mergeMCPConfig(&cfg.MCP, overrides.MCP)
|
||||
}
|
||||
|
||||
func mergeLocalControlConfig(dst *LocalControlConfig, src LocalControlConfig) {
|
||||
if src.enabledSet {
|
||||
dst.Enabled = src.Enabled
|
||||
dst.enabledSet = true
|
||||
}
|
||||
mergeLocalControlDriverConfig(&dst.Browser, src.Browser)
|
||||
mergeLocalControlDriverConfig(&dst.Desktop, src.Desktop)
|
||||
mergeLocalControlDriverConfig(&dst.Terminal, src.Terminal)
|
||||
if artifactsDir := strings.TrimSpace(src.ArtifactsDir); artifactsDir != "" {
|
||||
dst.ArtifactsDir = artifactsDir
|
||||
}
|
||||
}
|
||||
|
||||
func mergeLocalControlDriverConfig(dst *LocalControlDriverConfig, src LocalControlDriverConfig) {
|
||||
if src.enabledSet {
|
||||
dst.Enabled = src.Enabled
|
||||
dst.enabledSet = true
|
||||
}
|
||||
if helperPath := strings.TrimSpace(src.HelperPath); helperPath != "" {
|
||||
dst.HelperPath = helperPath
|
||||
}
|
||||
if driver := strings.TrimSpace(src.Driver); driver != "" {
|
||||
dst.Driver = driver
|
||||
}
|
||||
}
|
||||
|
||||
func mergeMCPConfig(dst *MCPConfig, src MCPConfig) {
|
||||
if len(src.Servers) == 0 {
|
||||
return
|
||||
|
||||
+16
-207
@@ -21,18 +21,13 @@ const (
|
||||
)
|
||||
|
||||
type ProviderProfile struct {
|
||||
Name string `json:"name"`
|
||||
Provider string `json:"provider,omitempty"`
|
||||
ProviderKind ProviderKind `json:"provider_kind,omitempty"`
|
||||
CatalogID string `json:"catalogID,omitempty"`
|
||||
BaseURL string `json:"baseURL,omitempty"`
|
||||
APIKey string `json:"apiKey,omitempty"`
|
||||
APIKeyEnv string `json:"apiKeyEnv,omitempty"`
|
||||
// APIKeyStored marks that this provider's API key lives in the encrypted
|
||||
// credential store (internal/credstore), not inline in APIKey. The effective
|
||||
// key is loaded from the store at provider-build time; config.json holds only
|
||||
// this marker, never the secret.
|
||||
APIKeyStored bool `json:"apiKeyStored,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Provider string `json:"provider,omitempty"`
|
||||
ProviderKind ProviderKind `json:"provider_kind,omitempty"`
|
||||
CatalogID string `json:"catalogID,omitempty"`
|
||||
BaseURL string `json:"baseURL,omitempty"`
|
||||
APIKey string `json:"apiKey,omitempty"`
|
||||
APIKeyEnv string `json:"apiKeyEnv,omitempty"`
|
||||
APIFormat string `json:"apiFormat,omitempty"`
|
||||
AuthHeader string `json:"authHeader,omitempty"`
|
||||
AuthScheme string `json:"authScheme,omitempty"`
|
||||
@@ -97,66 +92,6 @@ type ToolsConfig struct {
|
||||
|
||||
type PreferencesConfig struct {
|
||||
FavoriteModels []string `json:"favoriteModels,omitempty"`
|
||||
// Recaps is a tri-state: nil (unset) defaults to ON; an explicit false means
|
||||
// the user turned post-turn recaps off. A *bool is its own tri-state, so no
|
||||
// custom unmarshal is needed (unlike ToolsConfig.DeferThreshold's int).
|
||||
Recaps *bool `json:"recaps,omitempty"`
|
||||
}
|
||||
|
||||
// RecapsEnabled reports whether post-turn recaps are on. Unset defaults to ON.
|
||||
func (p PreferencesConfig) RecapsEnabled() bool {
|
||||
return p.Recaps == nil || *p.Recaps
|
||||
}
|
||||
|
||||
// LocalControlConfig controls local browser/desktop/terminal automation helpers.
|
||||
// Helpers are discovered lazily by the tool that needs them; no setup command or
|
||||
// background probe is required during startup.
|
||||
type LocalControlConfig struct {
|
||||
Enabled bool `json:"enabled,omitempty"`
|
||||
Browser LocalControlDriverConfig `json:"browser,omitempty"`
|
||||
Desktop LocalControlDriverConfig `json:"desktop,omitempty"`
|
||||
Terminal LocalControlDriverConfig `json:"terminal,omitempty"`
|
||||
ArtifactsDir string `json:"artifactsDir,omitempty"`
|
||||
enabledSet bool
|
||||
}
|
||||
|
||||
type LocalControlDriverConfig struct {
|
||||
Enabled bool `json:"enabled,omitempty"`
|
||||
HelperPath string `json:"helperPath,omitempty"`
|
||||
Driver string `json:"driver,omitempty"`
|
||||
enabledSet bool
|
||||
}
|
||||
|
||||
func (cfg LocalControlConfig) BrowserEnabled() bool {
|
||||
return !cfg.Disabled() && (!cfg.Browser.enabledSet || cfg.Browser.Enabled)
|
||||
}
|
||||
|
||||
func (cfg LocalControlConfig) DesktopEnabled() bool {
|
||||
return !cfg.Disabled() && cfg.Desktop.enabledSet && cfg.Desktop.Enabled
|
||||
}
|
||||
|
||||
func (cfg LocalControlConfig) TerminalEnabled() bool {
|
||||
return !cfg.Disabled() && (!cfg.Terminal.enabledSet || cfg.Terminal.Enabled)
|
||||
}
|
||||
|
||||
func (cfg LocalControlConfig) Disabled() bool {
|
||||
return cfg.enabledSet && !cfg.Enabled
|
||||
}
|
||||
|
||||
func (cfg LocalControlConfig) Empty() bool {
|
||||
return !cfg.Enabled &&
|
||||
!cfg.enabledSet &&
|
||||
cfg.ArtifactsDir == "" &&
|
||||
cfg.Browser.Empty() &&
|
||||
cfg.Desktop.Empty() &&
|
||||
cfg.Terminal.Empty()
|
||||
}
|
||||
|
||||
func (cfg LocalControlDriverConfig) Empty() bool {
|
||||
return !cfg.Enabled &&
|
||||
!cfg.enabledSet &&
|
||||
cfg.HelperPath == "" &&
|
||||
cfg.Driver == ""
|
||||
}
|
||||
|
||||
// SwarmConfig tunes the multi-agent swarm. MaxTeamSize caps how many members run
|
||||
@@ -193,46 +128,15 @@ func (cfg *ToolsConfig) UnmarshalJSON(data []byte) error {
|
||||
}
|
||||
|
||||
type FileConfig struct {
|
||||
ActiveProvider string `json:"activeProvider,omitempty"`
|
||||
Providers []ProviderProfile `json:"providers,omitempty"`
|
||||
MaxTurns int `json:"maxTurns,omitempty"`
|
||||
MCP MCPConfig `json:"mcp,omitempty"`
|
||||
Sandbox SandboxConfig `json:"sandbox,omitempty"`
|
||||
Notify NotifyConfig `json:"notify,omitempty"`
|
||||
Tools ToolsConfig `json:"tools,omitempty"`
|
||||
Swarm SwarmConfig `json:"swarm,omitempty"`
|
||||
Preferences PreferencesConfig `json:"preferences,omitempty"`
|
||||
LocalControl LocalControlConfig `json:"localControl,omitempty"`
|
||||
}
|
||||
|
||||
func (cfg FileConfig) MarshalJSON() ([]byte, error) {
|
||||
type rawConfig struct {
|
||||
ActiveProvider string `json:"activeProvider,omitempty"`
|
||||
Providers []ProviderProfile `json:"providers,omitempty"`
|
||||
MaxTurns int `json:"maxTurns,omitempty"`
|
||||
MCP MCPConfig `json:"mcp,omitempty"`
|
||||
Sandbox SandboxConfig `json:"sandbox,omitempty"`
|
||||
Notify NotifyConfig `json:"notify,omitempty"`
|
||||
Tools ToolsConfig `json:"tools,omitempty"`
|
||||
Swarm SwarmConfig `json:"swarm,omitempty"`
|
||||
Preferences PreferencesConfig `json:"preferences,omitempty"`
|
||||
LocalControl *LocalControlConfig `json:"localControl,omitempty"`
|
||||
}
|
||||
raw := rawConfig{
|
||||
ActiveProvider: cfg.ActiveProvider,
|
||||
Providers: cfg.Providers,
|
||||
MaxTurns: cfg.MaxTurns,
|
||||
MCP: cfg.MCP,
|
||||
Sandbox: cfg.Sandbox,
|
||||
Notify: cfg.Notify,
|
||||
Tools: cfg.Tools,
|
||||
Swarm: cfg.Swarm,
|
||||
Preferences: cfg.Preferences,
|
||||
}
|
||||
if !cfg.LocalControl.Empty() {
|
||||
raw.LocalControl = &cfg.LocalControl
|
||||
}
|
||||
return json.Marshal(raw)
|
||||
ActiveProvider string `json:"activeProvider,omitempty"`
|
||||
Providers []ProviderProfile `json:"providers,omitempty"`
|
||||
MaxTurns int `json:"maxTurns,omitempty"`
|
||||
MCP MCPConfig `json:"mcp,omitempty"`
|
||||
Sandbox SandboxConfig `json:"sandbox,omitempty"`
|
||||
Notify NotifyConfig `json:"notify,omitempty"`
|
||||
Tools ToolsConfig `json:"tools,omitempty"`
|
||||
Swarm SwarmConfig `json:"swarm,omitempty"`
|
||||
Preferences PreferencesConfig `json:"preferences,omitempty"`
|
||||
}
|
||||
|
||||
type ResolveOptions struct {
|
||||
@@ -252,7 +156,6 @@ type Overrides struct {
|
||||
Sandbox SandboxConfig
|
||||
Notify NotifyConfig
|
||||
Tools ToolsConfig
|
||||
LocalControl LocalControlConfig
|
||||
}
|
||||
|
||||
type ResolvedConfig struct {
|
||||
@@ -266,7 +169,6 @@ type ResolvedConfig struct {
|
||||
Tools ToolsConfig
|
||||
Swarm SwarmConfig
|
||||
Preferences PreferencesConfig
|
||||
LocalControl LocalControlConfig
|
||||
}
|
||||
|
||||
type MCPConfig struct {
|
||||
@@ -312,7 +214,6 @@ func (cfg *FileConfig) UnmarshalJSON(data []byte) error {
|
||||
Tools ToolsConfig `json:"tools"`
|
||||
Swarm SwarmConfig `json:"swarm"`
|
||||
Preferences PreferencesConfig `json:"preferences"`
|
||||
LocalControl LocalControlConfig `json:"localControl"`
|
||||
MCPServers map[string]MCPServerConfig `json:"mcpServers"`
|
||||
MCPServersSnake map[string]MCPServerConfig `json:"mcp_servers"`
|
||||
}
|
||||
@@ -338,7 +239,6 @@ func (cfg *FileConfig) UnmarshalJSON(data []byte) error {
|
||||
cfg.Tools = raw.Tools
|
||||
cfg.Swarm = raw.Swarm
|
||||
cfg.Preferences = raw.Preferences
|
||||
cfg.LocalControl = raw.LocalControl
|
||||
if cfg.MCP.Servers == nil && (len(raw.MCPServers) > 0 || len(raw.MCPServersSnake) > 0) {
|
||||
cfg.MCP.Servers = map[string]MCPServerConfig{}
|
||||
}
|
||||
@@ -354,94 +254,6 @@ func (cfg *FileConfig) UnmarshalJSON(data []byte) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (cfg *LocalControlConfig) UnmarshalJSON(data []byte) error {
|
||||
type rawLocalControl struct {
|
||||
Enabled *bool `json:"enabled"`
|
||||
Browser LocalControlDriverConfig `json:"browser"`
|
||||
Desktop LocalControlDriverConfig `json:"desktop"`
|
||||
Terminal LocalControlDriverConfig `json:"terminal"`
|
||||
ArtifactsDir string `json:"artifactsDir"`
|
||||
}
|
||||
var raw rawLocalControl
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return err
|
||||
}
|
||||
*cfg = LocalControlConfig{
|
||||
Browser: raw.Browser,
|
||||
Desktop: raw.Desktop,
|
||||
Terminal: raw.Terminal,
|
||||
ArtifactsDir: raw.ArtifactsDir,
|
||||
}
|
||||
if raw.Enabled != nil {
|
||||
cfg.Enabled = *raw.Enabled
|
||||
cfg.enabledSet = true
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (cfg LocalControlConfig) MarshalJSON() ([]byte, error) {
|
||||
type rawLocalControl struct {
|
||||
Enabled *bool `json:"enabled,omitempty"`
|
||||
Browser *LocalControlDriverConfig `json:"browser,omitempty"`
|
||||
Desktop *LocalControlDriverConfig `json:"desktop,omitempty"`
|
||||
Terminal *LocalControlDriverConfig `json:"terminal,omitempty"`
|
||||
ArtifactsDir string `json:"artifactsDir,omitempty"`
|
||||
}
|
||||
raw := rawLocalControl{
|
||||
ArtifactsDir: cfg.ArtifactsDir,
|
||||
}
|
||||
if cfg.enabledSet || cfg.Enabled {
|
||||
raw.Enabled = &cfg.Enabled
|
||||
}
|
||||
if !cfg.Browser.Empty() {
|
||||
raw.Browser = &cfg.Browser
|
||||
}
|
||||
if !cfg.Desktop.Empty() {
|
||||
raw.Desktop = &cfg.Desktop
|
||||
}
|
||||
if !cfg.Terminal.Empty() {
|
||||
raw.Terminal = &cfg.Terminal
|
||||
}
|
||||
return json.Marshal(raw)
|
||||
}
|
||||
|
||||
func (cfg *LocalControlDriverConfig) UnmarshalJSON(data []byte) error {
|
||||
type rawDriver struct {
|
||||
Enabled *bool `json:"enabled"`
|
||||
HelperPath string `json:"helperPath"`
|
||||
Driver string `json:"driver"`
|
||||
}
|
||||
var raw rawDriver
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return err
|
||||
}
|
||||
*cfg = LocalControlDriverConfig{
|
||||
HelperPath: raw.HelperPath,
|
||||
Driver: raw.Driver,
|
||||
}
|
||||
if raw.Enabled != nil {
|
||||
cfg.Enabled = *raw.Enabled
|
||||
cfg.enabledSet = true
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (cfg LocalControlDriverConfig) MarshalJSON() ([]byte, error) {
|
||||
type rawDriver struct {
|
||||
Enabled *bool `json:"enabled,omitempty"`
|
||||
HelperPath string `json:"helperPath,omitempty"`
|
||||
Driver string `json:"driver,omitempty"`
|
||||
}
|
||||
raw := rawDriver{
|
||||
HelperPath: cfg.HelperPath,
|
||||
Driver: cfg.Driver,
|
||||
}
|
||||
if cfg.enabledSet || cfg.Enabled {
|
||||
raw.Enabled = &cfg.Enabled
|
||||
}
|
||||
return json.Marshal(raw)
|
||||
}
|
||||
|
||||
func (server *MCPServerConfig) UnmarshalJSON(data []byte) error {
|
||||
type rawServer struct {
|
||||
Type string `json:"type"`
|
||||
@@ -490,8 +302,6 @@ func (profile *ProviderProfile) UnmarshalJSON(data []byte) error {
|
||||
APIKeySnake string `json:"api_key"`
|
||||
APIKeyEnv string `json:"apiKeyEnv"`
|
||||
APIKeyEnvSnake string `json:"api_key_env"`
|
||||
APIKeyStored bool `json:"apiKeyStored"`
|
||||
APIKeyStoredSnake bool `json:"api_key_stored"`
|
||||
APIFormat string `json:"apiFormat"`
|
||||
APIFormatSnake string `json:"api_format"`
|
||||
AuthHeader string `json:"authHeader"`
|
||||
@@ -521,7 +331,6 @@ func (profile *ProviderProfile) UnmarshalJSON(data []byte) error {
|
||||
profile.BaseURL = strings.TrimSpace(firstNonEmpty(raw.BaseURL, raw.BaseURLSnake))
|
||||
profile.APIKey = firstNonEmpty(raw.APIKey, raw.APIKeySnake)
|
||||
profile.APIKeyEnv = strings.TrimSpace(firstNonEmpty(raw.APIKeyEnv, raw.APIKeyEnvSnake))
|
||||
profile.APIKeyStored = raw.APIKeyStored || raw.APIKeyStoredSnake
|
||||
profile.APIFormat = strings.TrimSpace(firstNonEmpty(raw.APIFormat, raw.APIFormatSnake))
|
||||
profile.AuthHeader = strings.TrimSpace(firstNonEmpty(raw.AuthHeader, raw.AuthHeaderSnake))
|
||||
profile.AuthScheme = strings.TrimSpace(firstNonEmpty(raw.AuthScheme, raw.AuthSchemeSnake))
|
||||
|
||||
@@ -131,29 +131,6 @@ func SetFavoriteModels(path string, models []string) (FileConfig, error) {
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// SetRecapsEnabled persists the post-turn recap preference, mirroring
|
||||
// SetFavoriteModels (read-modify-atomic-write).
|
||||
func SetRecapsEnabled(path string, enabled bool) (FileConfig, error) {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
return FileConfig{}, fmt.Errorf("config path is required")
|
||||
}
|
||||
cfg := FileConfig{}
|
||||
if data, err := os.ReadFile(path); err == nil {
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
return FileConfig{}, fmt.Errorf("invalid config JSON %s: %w", path, err)
|
||||
}
|
||||
} else if !os.IsNotExist(err) {
|
||||
return FileConfig{}, fmt.Errorf("read config %s: %w", path, err)
|
||||
}
|
||||
v := enabled
|
||||
cfg.Preferences.Recaps = &v
|
||||
if err := writeConfigFile(path, cfg); err != nil {
|
||||
return FileConfig{}, err
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func normalizeFavoriteModels(models []string) []string {
|
||||
seen := map[string]bool{}
|
||||
favorites := make([]string, 0, len(models))
|
||||
|
||||
@@ -272,44 +272,6 @@ func TestSetFavoriteModelsPersistsUserPreferences(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecapsPreferenceRoundTrips(t *testing.T) {
|
||||
// Default (unset) is ON.
|
||||
if !(PreferencesConfig{}).RecapsEnabled() {
|
||||
t.Error("unset recaps should default to ON")
|
||||
}
|
||||
|
||||
path := filepath.Join(t.TempDir(), "zero.json")
|
||||
writeConfigFixture(t, path, FileConfig{ActiveProvider: "openai"}, 0o600)
|
||||
|
||||
// Persist OFF, then read it back.
|
||||
cfg, err := SetRecapsEnabled(path, false)
|
||||
if err != nil {
|
||||
t.Fatalf("SetRecapsEnabled(false) error = %v", err)
|
||||
}
|
||||
if cfg.Preferences.RecapsEnabled() {
|
||||
t.Error("after SetRecapsEnabled(false), RecapsEnabled() should be false")
|
||||
}
|
||||
persisted := readConfigFixture(t, path)
|
||||
if persisted.Preferences.Recaps == nil || *persisted.Preferences.Recaps {
|
||||
t.Errorf("persisted recaps should be explicit false, got %v", persisted.Preferences.Recaps)
|
||||
}
|
||||
if persisted.ActiveProvider != "openai" {
|
||||
t.Errorf("unrelated config must be preserved, got %q", persisted.ActiveProvider)
|
||||
}
|
||||
|
||||
// Flip back ON — the write must succeed and persist an explicit true.
|
||||
cfg, err = SetRecapsEnabled(path, true)
|
||||
if err != nil {
|
||||
t.Fatalf("SetRecapsEnabled(true) error = %v", err)
|
||||
}
|
||||
if !cfg.Preferences.RecapsEnabled() {
|
||||
t.Error("after SetRecapsEnabled(true), RecapsEnabled() should be true")
|
||||
}
|
||||
if reread := readConfigFixture(t, path); reread.Preferences.Recaps == nil || !*reread.Preferences.Recaps {
|
||||
t.Errorf("re-enable should persist an explicit true, got %v", reread.Preferences.Recaps)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetFavoriteModelsCreatesMissingConfig(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "zero", "config.json")
|
||||
|
||||
|
||||
@@ -221,12 +221,7 @@ func estimateRegistryTools(registry *tools.Registry) (int, int) {
|
||||
return all[left].Name() < all[right].Name()
|
||||
})
|
||||
total := 0
|
||||
count := 0
|
||||
for _, tool := range all {
|
||||
if tool.Safety().Permission == tools.PermissionDeny {
|
||||
continue
|
||||
}
|
||||
count++
|
||||
total += estimateTextTokens(tool.Name())
|
||||
total += estimateTextTokens(tool.Description())
|
||||
if encoded, err := json.Marshal(tool.Parameters()); err == nil {
|
||||
@@ -234,7 +229,7 @@ func estimateRegistryTools(registry *tools.Registry) (int, int) {
|
||||
}
|
||||
total += toolDefinitionOverheadTokens
|
||||
}
|
||||
return count, total
|
||||
return len(all), total
|
||||
}
|
||||
|
||||
func readProjectGuidelines(root string, names []string) (string, string) {
|
||||
|
||||
@@ -1,265 +0,0 @@
|
||||
// Package credstore persists provider API keys outside config.json so secrets are
|
||||
// not written to disk in cleartext. It resolves one backend at construction: the OS
|
||||
// keyring on macOS (the `security` keychain), otherwise an AES-256-GCM encrypted file
|
||||
// (the Linux secret-tool keyring needs a running secret service that is absent on
|
||||
// headless/CI machines, so it is opt-in via ZERO_CRED_STORAGE=keyring). A plaintext
|
||||
// file is available only as an explicit ZERO_CRED_STORAGE=file opt-out. OAuth tokens
|
||||
// stay in internal/oauth; this store is for raw API keys.
|
||||
package credstore
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/keyring"
|
||||
"github.com/Gitlawb/zero/internal/securefile"
|
||||
)
|
||||
|
||||
const (
|
||||
keyringService = "zero"
|
||||
keyringPrefix = "apikey:" // keyring account = "apikey:<provider>"
|
||||
|
||||
encryptedFileName = "credentials.enc"
|
||||
plaintextFileName = "credentials.json"
|
||||
)
|
||||
|
||||
// KeyringClient is the minimal OS-keyring surface the store needs;
|
||||
// *keyring.Keyring satisfies it. Injectable for tests.
|
||||
type KeyringClient interface {
|
||||
Available() bool
|
||||
Set(service, account, secret string) error
|
||||
Get(service, account string) (string, bool, error)
|
||||
Delete(service, account string) (bool, error)
|
||||
}
|
||||
|
||||
// Options configures the credential store.
|
||||
type Options struct {
|
||||
// Dir is the per-user data directory for the file backends.
|
||||
Dir string
|
||||
// Storage selects the backend: "" (auto), "keyring", "encrypted-file", or "file"
|
||||
// (plaintext opt-out). When empty it falls back to ZERO_CRED_STORAGE, then auto:
|
||||
// keyring on macOS (the `security` keychain is reliable and needs no daemon),
|
||||
// encrypted-file everywhere else (Linux `secret-tool` needs a running secret
|
||||
// service that is absent on headless/CI boxes and would block/error). Linux/other
|
||||
// users can still opt into the keyring with ZERO_CRED_STORAGE=keyring.
|
||||
Storage string
|
||||
// Keyring is the keyring client; nil => keyring.New().
|
||||
Keyring KeyringClient
|
||||
// Env reads environment variables; nil => os.Getenv.
|
||||
Env func(string) string
|
||||
// GOOS overrides the platform for auto backend resolution; "" => runtime.GOOS.
|
||||
// Exists so tests can exercise the per-OS default deterministically.
|
||||
GOOS string
|
||||
}
|
||||
|
||||
// Store reads/writes provider API keys via the resolved backend.
|
||||
type Store struct {
|
||||
backend string // "keyring", "encrypted-file", or "file"
|
||||
kr KeyringClient
|
||||
file string
|
||||
crypter *securefile.Crypter // non-nil only for "encrypted-file"
|
||||
}
|
||||
|
||||
// New resolves the backend. It never silently downgrades to plaintext: "file" must
|
||||
// be chosen explicitly; auto mode picks keyring then encrypted-file.
|
||||
func New(options Options) (*Store, error) {
|
||||
env := options.Env
|
||||
if env == nil {
|
||||
env = os.Getenv
|
||||
}
|
||||
kr := options.Keyring
|
||||
if kr == nil {
|
||||
kr = keyring.New()
|
||||
}
|
||||
goos := strings.TrimSpace(options.GOOS)
|
||||
if goos == "" {
|
||||
goos = runtime.GOOS
|
||||
}
|
||||
storage := strings.TrimSpace(options.Storage)
|
||||
if storage == "" {
|
||||
storage = strings.TrimSpace(env("ZERO_CRED_STORAGE"))
|
||||
}
|
||||
if storage == "" {
|
||||
// Keyring is the default only on macOS, where `security` is reliable and needs
|
||||
// no running daemon. Elsewhere (Linux secret-tool, headless/CI) default to the
|
||||
// encrypted file; keyring stays available via an explicit ZERO_CRED_STORAGE.
|
||||
if goos == "darwin" && kr.Available() {
|
||||
storage = "keyring"
|
||||
} else {
|
||||
storage = "encrypted-file"
|
||||
}
|
||||
}
|
||||
|
||||
switch storage {
|
||||
case "keyring":
|
||||
if !kr.Available() {
|
||||
return nil, fmt.Errorf("credstore: keyring requested but no OS keyring backend on this platform; use encrypted-file")
|
||||
}
|
||||
return &Store{backend: "keyring", kr: kr}, nil
|
||||
case "encrypted-file":
|
||||
if strings.TrimSpace(options.Dir) == "" {
|
||||
return nil, fmt.Errorf("credstore: Dir is required for encrypted-file storage")
|
||||
}
|
||||
path := filepath.Join(options.Dir, encryptedFileName)
|
||||
return &Store{backend: "encrypted-file", file: path, crypter: securefile.NewCrypter(path + ".secret")}, nil
|
||||
case "file":
|
||||
if strings.TrimSpace(options.Dir) == "" {
|
||||
return nil, fmt.Errorf("credstore: Dir is required for file storage")
|
||||
}
|
||||
return &Store{backend: "file", file: filepath.Join(options.Dir, plaintextFileName)}, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("credstore: unknown storage %q (want \"keyring\", \"encrypted-file\", or \"file\")", storage)
|
||||
}
|
||||
}
|
||||
|
||||
// Backend reports the resolved backend ("keyring", "encrypted-file", "file"), for
|
||||
// display ("how is this key stored").
|
||||
func (s *Store) Backend() string { return s.backend }
|
||||
|
||||
// Encrypted reports whether secrets are protected at rest (keyring or AES file).
|
||||
func (s *Store) Encrypted() bool { return s.backend == "keyring" || s.backend == "encrypted-file" }
|
||||
|
||||
// Set stores (or overwrites) the API key for provider.
|
||||
func (s *Store) Set(provider, key string) error {
|
||||
provider = normalizeProvider(provider)
|
||||
if provider == "" {
|
||||
return fmt.Errorf("credstore: provider is required")
|
||||
}
|
||||
if s.backend == "keyring" {
|
||||
return s.kr.Set(keyringService, keyringPrefix+provider, key)
|
||||
}
|
||||
data, err := s.read()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data[provider] = key
|
||||
return s.write(data)
|
||||
}
|
||||
|
||||
// Get returns the API key for provider and whether one is stored.
|
||||
func (s *Store) Get(provider string) (string, bool, error) {
|
||||
provider = normalizeProvider(provider)
|
||||
if provider == "" {
|
||||
return "", false, nil
|
||||
}
|
||||
if s.backend == "keyring" {
|
||||
return s.kr.Get(keyringService, keyringPrefix+provider)
|
||||
}
|
||||
data, err := s.read()
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
key, ok := data[provider]
|
||||
return key, ok, nil
|
||||
}
|
||||
|
||||
// Delete removes the API key for provider, reporting whether one existed.
|
||||
func (s *Store) Delete(provider string) (bool, error) {
|
||||
provider = normalizeProvider(provider)
|
||||
if provider == "" {
|
||||
return false, nil
|
||||
}
|
||||
if s.backend == "keyring" {
|
||||
return s.kr.Delete(keyringService, keyringPrefix+provider)
|
||||
}
|
||||
data, err := s.read()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if _, ok := data[provider]; !ok {
|
||||
return false, nil
|
||||
}
|
||||
delete(data, provider)
|
||||
return true, s.write(data)
|
||||
}
|
||||
|
||||
// Providers lists providers with a stored key (sorted), for display/audit.
|
||||
func (s *Store) Providers() ([]string, error) {
|
||||
if s.backend == "keyring" {
|
||||
// The OS keyring has no enumerate-by-prefix in our minimal surface; callers
|
||||
// that need a list pass known provider names to Get. Return empty here.
|
||||
return nil, nil
|
||||
}
|
||||
data, err := s.read()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
names := make([]string, 0, len(data))
|
||||
for name := range data {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
return names, nil
|
||||
}
|
||||
|
||||
// --- file backends (encrypted-file / file) ---------------------------------
|
||||
|
||||
func (s *Store) read() (map[string]string, error) {
|
||||
raw, err := os.ReadFile(s.file)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return map[string]string{}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("credstore: read %s: %w", s.file, err)
|
||||
}
|
||||
if s.crypter != nil {
|
||||
raw, err = s.crypter.Open(raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
data := map[string]string{}
|
||||
if len(raw) == 0 {
|
||||
return data, nil
|
||||
}
|
||||
if err := json.Unmarshal(raw, &data); err != nil {
|
||||
return nil, fmt.Errorf("credstore: parse %s: %w", s.file, err)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func (s *Store) write(data map[string]string) error {
|
||||
payload, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("credstore: encode: %w", err)
|
||||
}
|
||||
if s.crypter != nil {
|
||||
payload, err = s.crypter.Seal(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(s.file), 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
tmp, err := os.CreateTemp(filepath.Dir(s.file), filepath.Base(s.file)+".*.tmp")
|
||||
if err != nil {
|
||||
return fmt.Errorf("credstore: temp file: %w", err)
|
||||
}
|
||||
tmpPath := tmp.Name()
|
||||
defer os.Remove(tmpPath)
|
||||
if err := tmp.Chmod(0o600); err != nil {
|
||||
_ = tmp.Close()
|
||||
return fmt.Errorf("credstore: chmod: %w", err)
|
||||
}
|
||||
if _, err := tmp.Write(payload); err != nil {
|
||||
_ = tmp.Close()
|
||||
return fmt.Errorf("credstore: write: %w", err)
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return fmt.Errorf("credstore: write: %w", err)
|
||||
}
|
||||
if err := os.Rename(tmpPath, s.file); err != nil {
|
||||
return fmt.Errorf("credstore: publish: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeProvider(provider string) string {
|
||||
return strings.ToLower(strings.TrimSpace(provider))
|
||||
}
|
||||
@@ -1,174 +0,0 @@
|
||||
package credstore
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// fakeKeyring is an in-memory KeyringClient so tests never touch the real OS
|
||||
// keychain.
|
||||
type fakeKeyring struct {
|
||||
available bool
|
||||
data map[string]string
|
||||
}
|
||||
|
||||
func newFakeKeyring(available bool) *fakeKeyring {
|
||||
return &fakeKeyring{available: available, data: map[string]string{}}
|
||||
}
|
||||
func (f *fakeKeyring) Available() bool { return f.available }
|
||||
func (f *fakeKeyring) Set(service, account, secret string) error {
|
||||
f.data[service+"/"+account] = secret
|
||||
return nil
|
||||
}
|
||||
func (f *fakeKeyring) Get(service, account string) (string, bool, error) {
|
||||
v, ok := f.data[service+"/"+account]
|
||||
return v, ok, nil
|
||||
}
|
||||
func (f *fakeKeyring) Delete(service, account string) (bool, error) {
|
||||
k := service + "/" + account
|
||||
_, ok := f.data[k]
|
||||
delete(f.data, k)
|
||||
return ok, nil
|
||||
}
|
||||
|
||||
func noEnv(string) string { return "" }
|
||||
|
||||
func roundTrip(t *testing.T, s *Store) {
|
||||
t.Helper()
|
||||
if _, ok, _ := s.Get("openai"); ok {
|
||||
t.Fatal("expected no key initially")
|
||||
}
|
||||
if err := s.Set("OpenAI", "sk-abc-123"); err != nil { // mixed case normalizes
|
||||
t.Fatalf("set: %v", err)
|
||||
}
|
||||
got, ok, err := s.Get("openai")
|
||||
if err != nil || !ok || got != "sk-abc-123" {
|
||||
t.Fatalf("get = %q,%v,%v; want sk-abc-123,true,nil", got, ok, err)
|
||||
}
|
||||
if err := s.Set("openai", "sk-new-456"); err != nil { // overwrite
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, _, _ := s.Get("openai"); got != "sk-new-456" {
|
||||
t.Fatalf("overwrite get = %q, want sk-new-456", got)
|
||||
}
|
||||
existed, err := s.Delete("openai")
|
||||
if err != nil || !existed {
|
||||
t.Fatalf("delete = %v,%v; want true,nil", existed, err)
|
||||
}
|
||||
if _, ok, _ := s.Get("openai"); ok {
|
||||
t.Fatal("expected key gone after delete")
|
||||
}
|
||||
if existed, _ := s.Delete("openai"); existed {
|
||||
t.Fatal("delete of absent key should report not-existed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeyringBackendRoundTrip(t *testing.T) {
|
||||
s, err := New(Options{Storage: "keyring", Keyring: newFakeKeyring(true), Env: noEnv})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.Backend() != "keyring" || !s.Encrypted() {
|
||||
t.Fatalf("backend=%q encrypted=%v", s.Backend(), s.Encrypted())
|
||||
}
|
||||
roundTrip(t, s)
|
||||
}
|
||||
|
||||
func TestEncryptedFileBackendRoundTripAndAtRest(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
s, err := New(Options{Storage: "encrypted-file", Dir: dir, Keyring: newFakeKeyring(false), Env: noEnv})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !s.Encrypted() {
|
||||
t.Fatal("encrypted-file must report Encrypted()")
|
||||
}
|
||||
if err := s.Set("anthropic", "sk-ant-SECRET"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The on-disk file must NOT contain the plaintext key, and must be 0600.
|
||||
raw, err := os.ReadFile(filepath.Join(dir, encryptedFileName))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(raw), "sk-ant-SECRET") {
|
||||
t.Fatal("encrypted-file leaked the plaintext key on disk")
|
||||
}
|
||||
if runtime.GOOS != "windows" { // Windows doesn't honor Unix permission bits
|
||||
info, _ := os.Stat(filepath.Join(dir, encryptedFileName))
|
||||
if info.Mode().Perm() != 0o600 {
|
||||
t.Fatalf("perm = %o, want 600", info.Mode().Perm())
|
||||
}
|
||||
}
|
||||
if got, ok, _ := s.Get("anthropic"); !ok || got != "sk-ant-SECRET" {
|
||||
t.Fatalf("get = %q,%v", got, ok)
|
||||
}
|
||||
roundTrip(t, s)
|
||||
}
|
||||
|
||||
func TestPlaintextFileBackendIsOptOut(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
s, err := New(Options{Storage: "file", Dir: dir, Keyring: newFakeKeyring(false), Env: noEnv})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.Encrypted() {
|
||||
t.Fatal("plaintext file must NOT report Encrypted()")
|
||||
}
|
||||
roundTrip(t, s)
|
||||
}
|
||||
|
||||
func TestAutoPrefersKeyringThenEncryptedFile(t *testing.T) {
|
||||
// macOS + keyring available -> keyring
|
||||
s, err := New(Options{Dir: t.TempDir(), Keyring: newFakeKeyring(true), GOOS: "darwin", Env: noEnv})
|
||||
if err != nil || s.Backend() != "keyring" {
|
||||
t.Fatalf("auto on darwin with keyring = %q,%v; want keyring", s.Backend(), err)
|
||||
}
|
||||
// macOS but keyring unavailable -> encrypted-file (never plaintext)
|
||||
s2, err := New(Options{Dir: t.TempDir(), Keyring: newFakeKeyring(false), GOOS: "darwin", Env: noEnv})
|
||||
if err != nil || s2.Backend() != "encrypted-file" {
|
||||
t.Fatalf("auto on darwin without keyring = %q,%v; want encrypted-file", s2.Backend(), err)
|
||||
}
|
||||
// Non-macOS (e.g. Linux) -> encrypted-file even when a keyring is available, since
|
||||
// secret-tool needs a running secret service (keyring stays opt-in via env).
|
||||
s3, err := New(Options{Dir: t.TempDir(), Keyring: newFakeKeyring(true), GOOS: "linux", Env: noEnv})
|
||||
if err != nil || s3.Backend() != "encrypted-file" {
|
||||
t.Fatalf("auto on linux = %q,%v; want encrypted-file", s3.Backend(), err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeyringRequestedButUnavailableErrors(t *testing.T) {
|
||||
if _, err := New(Options{Storage: "keyring", Keyring: newFakeKeyring(false), Env: noEnv}); err == nil {
|
||||
t.Fatal("expected error when keyring requested but unavailable")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnvSelectsStorage(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
env := func(k string) string {
|
||||
if k == "ZERO_CRED_STORAGE" {
|
||||
return "encrypted-file"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
s, err := New(Options{Dir: dir, Keyring: newFakeKeyring(true), Env: env}) // keyring available, but env forces encrypted-file
|
||||
if err != nil || s.Backend() != "encrypted-file" {
|
||||
t.Fatalf("env override = %q,%v; want encrypted-file", s.Backend(), err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvidersListsFileBackend(t *testing.T) {
|
||||
s, _ := New(Options{Storage: "encrypted-file", Dir: t.TempDir(), Keyring: newFakeKeyring(false), Env: noEnv})
|
||||
_ = s.Set("openai", "a")
|
||||
_ = s.Set("gemini", "b")
|
||||
names, err := s.Providers()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(names) != 2 || names[0] != "gemini" || names[1] != "openai" {
|
||||
t.Fatalf("providers = %v, want [gemini openai]", names)
|
||||
}
|
||||
}
|
||||
@@ -31,9 +31,6 @@ func TestUnixInstallerScriptMatchesReleaseContracts(t *testing.T) {
|
||||
`tar -xzf "$archive_path" -C "$extract_dir"`,
|
||||
`find_extracted_binary "$extract_dir"`,
|
||||
`cp "$binary_path" "$ZERO_INSTALL_DIR/zero"`,
|
||||
`copy_optional_file "zero-linux-sandbox"`,
|
||||
`copy_optional_file "zero-seccomp"`,
|
||||
`copy_optional_dir "helpers"`,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -51,8 +48,6 @@ func TestPowerShellInstallerScriptMatchesWindowsReleaseContracts(t *testing.T) {
|
||||
`"zero-windows-command-runner.exe"`,
|
||||
`"zero-windows-sandbox-setup.exe"`,
|
||||
`Copy-Item -Path $sourcePath -Destination (Join-Path $InstallDir $fileName) -Force`,
|
||||
`Find-ZeroOptionalExtractedDirectory -Root $extractDir -DirectoryName "helpers"`,
|
||||
`Copy-Item -Path $helpersPath -Destination $targetHelpersPath -Recurse -Force`,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -73,12 +68,6 @@ func TestUnixInstallerInstallsFromPrefixedReleaseArchiveWithoutNetwork(t *testin
|
||||
if !strings.Contains(string(installed), "mock-zero") {
|
||||
t.Fatalf("installed binary = %q, want mock-zero script", string(installed))
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(fixture.installDir, "helpers", "node_modules", ".bin", "agent-browser")); err != nil {
|
||||
t.Fatalf("installed helper package missing: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(fixture.installDir, "zero-linux-sandbox")); err != nil {
|
||||
t.Fatalf("installed linux sandbox helper missing: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnixInstallerRejectsChecksumMismatchWithoutNetwork(t *testing.T) {
|
||||
@@ -138,16 +127,7 @@ func newUnixInstallFixture(t *testing.T) unixInstallFixture {
|
||||
mustMkdirAll(t, packageDir)
|
||||
mustMkdirAll(t, filepath.Dir(fixture.archivePath))
|
||||
mustMkdirAll(t, fixture.installDir)
|
||||
mustMkdirAll(t, filepath.Join(packageDir, "helpers", "node_modules", ".bin"))
|
||||
writeFile(t, filepath.Join(packageDir, "zero"), []byte("#!/usr/bin/env sh\necho mock-zero\n"), 0o755)
|
||||
writeFile(t, filepath.Join(packageDir, "zero-linux-sandbox"), []byte("#!/usr/bin/env sh\n"), 0o755)
|
||||
writeFile(t, filepath.Join(packageDir, "zero-seccomp"), []byte("#!/usr/bin/env sh\n"), 0o755)
|
||||
mustMkdirAll(t, filepath.Join(packageDir, "helpers", "node_modules", "agent-browser", "bin"))
|
||||
writeFile(t, filepath.Join(packageDir, "helpers", "node_modules", "agent-browser", "bin", "agent-browser.js"), []byte("#!/usr/bin/env node\n"), 0o755)
|
||||
if err := os.Symlink("../agent-browser/bin/agent-browser.js", filepath.Join(packageDir, "helpers", "node_modules", ".bin", "agent-browser")); err != nil {
|
||||
t.Fatalf("Symlink agent-browser helper: %v", err)
|
||||
}
|
||||
writeFile(t, filepath.Join(packageDir, "helpers", "node_modules", ".bin", "tuistory"), []byte("#!/usr/bin/env sh\n"), 0o755)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
@@ -1,189 +0,0 @@
|
||||
package localcontrol
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
DefaultDevToolsPort = 9222
|
||||
defaultDevToolsWaitTimeout = 20 * time.Second
|
||||
)
|
||||
|
||||
type BrowserAppLaunchOptions struct {
|
||||
Runner BrowserAppRunner
|
||||
WaitTimeout time.Duration
|
||||
}
|
||||
|
||||
type BrowserAppLaunchRequest struct {
|
||||
App string
|
||||
DebugPort int
|
||||
StopExisting bool
|
||||
Wait bool
|
||||
}
|
||||
|
||||
type BrowserAppLaunchResult struct {
|
||||
App string
|
||||
Command string
|
||||
Args []string
|
||||
PID int
|
||||
DebugPort int
|
||||
DevToolsURL string
|
||||
}
|
||||
|
||||
type BrowserAppRunner interface {
|
||||
Run(ctx context.Context, path string, args []string, env []string, timeout time.Duration) (CommandResult, error)
|
||||
StartDetached(ctx context.Context, path string, args []string, env []string) (int, error)
|
||||
}
|
||||
|
||||
type BrowserAppLauncher struct {
|
||||
runner BrowserAppRunner
|
||||
waitTimeout time.Duration
|
||||
}
|
||||
|
||||
type browserAppSpec struct {
|
||||
name string
|
||||
command string
|
||||
args []string
|
||||
env []string
|
||||
stopPath string
|
||||
stopArgs []string
|
||||
}
|
||||
|
||||
func NewBrowserAppLauncher(options BrowserAppLaunchOptions) BrowserAppLauncher {
|
||||
if options.Runner == nil {
|
||||
options.Runner = ExecBrowserAppRunner{}
|
||||
}
|
||||
if options.WaitTimeout <= 0 {
|
||||
options.WaitTimeout = defaultDevToolsWaitTimeout
|
||||
}
|
||||
return BrowserAppLauncher{
|
||||
runner: options.Runner,
|
||||
waitTimeout: options.WaitTimeout,
|
||||
}
|
||||
}
|
||||
|
||||
func (launcher BrowserAppLauncher) LaunchBrowserApp(ctx context.Context, request BrowserAppLaunchRequest) (BrowserAppLaunchResult, error) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
port := request.DebugPort
|
||||
if port == 0 {
|
||||
port = DefaultDevToolsPort
|
||||
}
|
||||
if port < 1024 || port > 65535 {
|
||||
return BrowserAppLaunchResult{}, fmt.Errorf("debug_port must be between 1024 and 65535")
|
||||
}
|
||||
spec, err := browserAppLaunchSpec(request.App, port)
|
||||
if err != nil {
|
||||
return BrowserAppLaunchResult{}, err
|
||||
}
|
||||
|
||||
if request.StopExisting && spec.stopPath != "" {
|
||||
_, _ = launcher.runner.Run(ctx, spec.stopPath, spec.stopArgs, nil, 5*time.Second)
|
||||
}
|
||||
|
||||
pid, err := launcher.runner.StartDetached(ctx, spec.command, spec.args, spec.env)
|
||||
if err != nil {
|
||||
return BrowserAppLaunchResult{}, fmt.Errorf("launch %s: %w", spec.name, err)
|
||||
}
|
||||
|
||||
devToolsURL := "http://127.0.0.1:" + strconv.Itoa(port)
|
||||
if request.Wait {
|
||||
if err := waitForDevTools(ctx, devToolsURL, launcher.waitTimeout); err != nil {
|
||||
return BrowserAppLaunchResult{}, err
|
||||
}
|
||||
}
|
||||
|
||||
return BrowserAppLaunchResult{
|
||||
App: spec.name,
|
||||
Command: spec.command,
|
||||
Args: append([]string(nil), spec.args...),
|
||||
PID: pid,
|
||||
DebugPort: port,
|
||||
DevToolsURL: devToolsURL,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func browserAppLaunchSpec(app string, port int) (browserAppSpec, error) {
|
||||
switch strings.ToLower(strings.TrimSpace(app)) {
|
||||
case "discord":
|
||||
if runtime.GOOS != "linux" {
|
||||
return browserAppSpec{}, fmt.Errorf("discord launch is only supported for Linux flatpak installs")
|
||||
}
|
||||
return browserAppSpec{
|
||||
name: "discord",
|
||||
command: "flatpak",
|
||||
args: []string{"run", "--command=com.discordapp.Discord", "com.discordapp.Discord", "--remote-debugging-port=" + strconv.Itoa(port)},
|
||||
stopPath: "flatpak",
|
||||
stopArgs: []string{"kill", "com.discordapp.Discord"},
|
||||
}, nil
|
||||
default:
|
||||
return browserAppSpec{}, fmt.Errorf("app must be one of: discord")
|
||||
}
|
||||
}
|
||||
|
||||
func waitForDevTools(ctx context.Context, baseURL string, timeout time.Duration) error {
|
||||
if timeout <= 0 {
|
||||
timeout = defaultDevToolsWaitTimeout
|
||||
}
|
||||
deadline := time.Now().Add(timeout)
|
||||
client := http.Client{Timeout: 750 * time.Millisecond}
|
||||
endpoint := strings.TrimRight(baseURL, "/") + "/json/version"
|
||||
var lastErr error
|
||||
for {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err == nil {
|
||||
_ = resp.Body.Close()
|
||||
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
||||
return nil
|
||||
}
|
||||
lastErr = fmt.Errorf("DevTools endpoint returned HTTP %d", resp.StatusCode)
|
||||
} else {
|
||||
lastErr = err
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
return fmt.Errorf("DevTools endpoint %s was not ready after %dms: %w", endpoint, timeout.Milliseconds(), lastErr)
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(300 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type ExecBrowserAppRunner struct{}
|
||||
|
||||
func (ExecBrowserAppRunner) Run(ctx context.Context, path string, args []string, env []string, timeout time.Duration) (CommandResult, error) {
|
||||
return ExecRunner{}.Run(ctx, path, args, env, timeout)
|
||||
}
|
||||
|
||||
func (ExecBrowserAppRunner) StartDetached(_ context.Context, path string, args []string, env []string) (int, error) {
|
||||
if strings.TrimSpace(path) == "" {
|
||||
return 0, errors.New("launch command is required")
|
||||
}
|
||||
cmd := exec.Command(path, args...)
|
||||
cmd.Env = mergeEnv(os.Environ(), env)
|
||||
configureDetachedProcess(cmd)
|
||||
if err := cmd.Start(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
pid := cmd.Process.Pid
|
||||
if err := cmd.Process.Release(); err != nil {
|
||||
return pid, err
|
||||
}
|
||||
return pid, nil
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
package localcontrol
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"reflect"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type fakeBrowserAppRunner struct {
|
||||
runPath string
|
||||
runArgs []string
|
||||
startPath string
|
||||
startArgs []string
|
||||
pid int
|
||||
err error
|
||||
}
|
||||
|
||||
func (runner *fakeBrowserAppRunner) Run(_ context.Context, path string, args []string, _ []string, _ time.Duration) (CommandResult, error) {
|
||||
runner.runPath = path
|
||||
runner.runArgs = append([]string(nil), args...)
|
||||
return CommandResult{Path: path, Args: append([]string(nil), args...), ExitCode: 0}, nil
|
||||
}
|
||||
|
||||
func (runner *fakeBrowserAppRunner) StartDetached(_ context.Context, path string, args []string, _ []string) (int, error) {
|
||||
runner.startPath = path
|
||||
runner.startArgs = append([]string(nil), args...)
|
||||
if runner.err != nil {
|
||||
return 0, runner.err
|
||||
}
|
||||
if runner.pid == 0 {
|
||||
runner.pid = 4321
|
||||
}
|
||||
return runner.pid, nil
|
||||
}
|
||||
|
||||
func TestBrowserAppLauncherMapsDiscordToFlatpakDevToolsLaunch(t *testing.T) {
|
||||
requireLinuxFlatpakLaunch(t)
|
||||
runner := &fakeBrowserAppRunner{}
|
||||
launcher := NewBrowserAppLauncher(BrowserAppLaunchOptions{Runner: runner})
|
||||
|
||||
result, err := launcher.LaunchBrowserApp(context.Background(), BrowserAppLaunchRequest{
|
||||
App: "discord",
|
||||
DebugPort: 9222,
|
||||
StopExisting: true,
|
||||
Wait: false,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("LaunchBrowserApp returned error: %v", err)
|
||||
}
|
||||
if runner.runPath != "flatpak" {
|
||||
t.Fatalf("stop path = %q, want flatpak", runner.runPath)
|
||||
}
|
||||
if want := []string{"kill", "com.discordapp.Discord"}; !reflect.DeepEqual(runner.runArgs, want) {
|
||||
t.Fatalf("stop args = %#v, want %#v", runner.runArgs, want)
|
||||
}
|
||||
if runner.startPath != "flatpak" {
|
||||
t.Fatalf("start path = %q, want flatpak", runner.startPath)
|
||||
}
|
||||
wantStartArgs := []string{"run", "--command=com.discordapp.Discord", "com.discordapp.Discord", "--remote-debugging-port=9222"}
|
||||
if !reflect.DeepEqual(runner.startArgs, wantStartArgs) {
|
||||
t.Fatalf("start args = %#v, want %#v", runner.startArgs, wantStartArgs)
|
||||
}
|
||||
if result.PID != 4321 || result.DevToolsURL != "http://127.0.0.1:9222" {
|
||||
t.Fatalf("result = %#v, want pid/devtools URL", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBrowserAppLauncherDefaultsPortAndCanSkipStop(t *testing.T) {
|
||||
requireLinuxFlatpakLaunch(t)
|
||||
runner := &fakeBrowserAppRunner{}
|
||||
launcher := NewBrowserAppLauncher(BrowserAppLaunchOptions{Runner: runner})
|
||||
|
||||
result, err := launcher.LaunchBrowserApp(context.Background(), BrowserAppLaunchRequest{
|
||||
App: "discord",
|
||||
Wait: false,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("LaunchBrowserApp returned error: %v", err)
|
||||
}
|
||||
if runner.runPath != "" {
|
||||
t.Fatalf("stop path = %q, want no stop", runner.runPath)
|
||||
}
|
||||
wantStartArgs := []string{"run", "--command=com.discordapp.Discord", "com.discordapp.Discord", "--remote-debugging-port=9222"}
|
||||
if !reflect.DeepEqual(runner.startArgs, wantStartArgs) {
|
||||
t.Fatalf("start args = %#v, want %#v", runner.startArgs, wantStartArgs)
|
||||
}
|
||||
if result.DebugPort != DefaultDevToolsPort {
|
||||
t.Fatalf("debug port = %d, want %d", result.DebugPort, DefaultDevToolsPort)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBrowserAppLauncherRejectsUnsupportedApp(t *testing.T) {
|
||||
launcher := NewBrowserAppLauncher(BrowserAppLaunchOptions{Runner: &fakeBrowserAppRunner{}})
|
||||
_, err := launcher.LaunchBrowserApp(context.Background(), BrowserAppLaunchRequest{App: "telegram"})
|
||||
if err == nil || !strings.Contains(err.Error(), "app must be one of") {
|
||||
t.Fatalf("error = %v, want unsupported app", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBrowserAppLauncherReportsStartFailure(t *testing.T) {
|
||||
requireLinuxFlatpakLaunch(t)
|
||||
launcher := NewBrowserAppLauncher(BrowserAppLaunchOptions{Runner: &fakeBrowserAppRunner{err: errors.New("boom")}})
|
||||
_, err := launcher.LaunchBrowserApp(context.Background(), BrowserAppLaunchRequest{App: "discord", Wait: false})
|
||||
if err == nil || !strings.Contains(err.Error(), "launch discord") {
|
||||
t.Fatalf("error = %v, want launch failure", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBrowserAppLauncherRejectsDiscordOnUnsupportedPlatform(t *testing.T) {
|
||||
if runtime.GOOS == "linux" {
|
||||
t.Skip("discord launch is supported on Linux")
|
||||
}
|
||||
launcher := NewBrowserAppLauncher(BrowserAppLaunchOptions{Runner: &fakeBrowserAppRunner{}})
|
||||
_, err := launcher.LaunchBrowserApp(context.Background(), BrowserAppLaunchRequest{App: "discord", Wait: false})
|
||||
if err == nil || !strings.Contains(err.Error(), "only supported") {
|
||||
t.Fatalf("error = %v, want unsupported platform", err)
|
||||
}
|
||||
}
|
||||
|
||||
func requireLinuxFlatpakLaunch(t *testing.T) {
|
||||
t.Helper()
|
||||
if runtime.GOOS != "linux" {
|
||||
t.Skip("discord launch uses Linux flatpak")
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
//go:build !windows
|
||||
|
||||
package localcontrol
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
func configureDetachedProcess(cmd *exec.Cmd) {
|
||||
if cmd.SysProcAttr == nil {
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{}
|
||||
}
|
||||
cmd.SysProcAttr.Setsid = true
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
//go:build windows
|
||||
|
||||
package localcontrol
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
const detachedProcess = 0x00000008
|
||||
|
||||
func configureDetachedProcess(cmd *exec.Cmd) {
|
||||
if cmd.SysProcAttr == nil {
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{}
|
||||
}
|
||||
cmd.SysProcAttr.CreationFlags |= syscall.CREATE_NEW_PROCESS_GROUP | detachedProcess
|
||||
}
|
||||
@@ -1,451 +0,0 @@
|
||||
package localcontrol
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
DefaultBrowserDriver = "agent-browser"
|
||||
DefaultDesktopDriver = "cua-driver"
|
||||
DefaultTerminalDriver = "tuistory"
|
||||
DefaultTimeout = 30 * time.Second
|
||||
EnvHelperManifest = "ZERO_LOCAL_CONTROL_HELPERS"
|
||||
)
|
||||
|
||||
type HelperOptions struct {
|
||||
Enabled bool
|
||||
Driver string
|
||||
HelperPath string
|
||||
ConfigPath string
|
||||
DisabledMessage string
|
||||
MissingHint string
|
||||
Timeout time.Duration
|
||||
Runner CommandRunner
|
||||
}
|
||||
|
||||
type BrowserOptions = HelperOptions
|
||||
type DesktopOptions = HelperOptions
|
||||
type TerminalOptions = HelperOptions
|
||||
|
||||
type CommandResult struct {
|
||||
Path string
|
||||
Args []string
|
||||
Stdout string
|
||||
Stderr string
|
||||
ExitCode int
|
||||
}
|
||||
|
||||
func (result CommandResult) Output() string {
|
||||
stdout := strings.TrimRight(result.Stdout, "\n")
|
||||
stderr := strings.TrimRight(result.Stderr, "\n")
|
||||
switch {
|
||||
case stdout == "" && stderr == "":
|
||||
return ""
|
||||
case stdout == "":
|
||||
return stderr
|
||||
case stderr == "":
|
||||
return stdout
|
||||
default:
|
||||
return stdout + "\n" + stderr
|
||||
}
|
||||
}
|
||||
|
||||
type CommandRunner interface {
|
||||
Run(ctx context.Context, path string, args []string, env []string, timeout time.Duration) (CommandResult, error)
|
||||
}
|
||||
|
||||
type ResolvedHelper struct {
|
||||
Command string
|
||||
PrefixArgs []string
|
||||
Env []string
|
||||
}
|
||||
|
||||
type Helper struct {
|
||||
options HelperOptions
|
||||
}
|
||||
|
||||
type Browser struct {
|
||||
helper Helper
|
||||
}
|
||||
|
||||
type Desktop struct {
|
||||
helper Helper
|
||||
}
|
||||
|
||||
type Terminal struct {
|
||||
helper Helper
|
||||
}
|
||||
|
||||
func NewHelper(options HelperOptions) Helper {
|
||||
if options.Timeout <= 0 {
|
||||
options.Timeout = DefaultTimeout
|
||||
}
|
||||
if options.Runner == nil {
|
||||
options.Runner = ExecRunner{}
|
||||
}
|
||||
return Helper{options: options}
|
||||
}
|
||||
|
||||
func NewBrowser(options BrowserOptions) Browser {
|
||||
if strings.TrimSpace(options.Driver) == "" {
|
||||
options.Driver = DefaultBrowserDriver
|
||||
}
|
||||
options.ConfigPath = "localControl.browser.helperPath"
|
||||
options.DisabledMessage = "local browser control is disabled by user config"
|
||||
options.MissingHint = "install the agent-browser package or set localControl.browser.helperPath"
|
||||
return Browser{helper: NewHelper(options)}
|
||||
}
|
||||
|
||||
func NewDesktop(options DesktopOptions) Desktop {
|
||||
if strings.TrimSpace(options.Driver) == "" {
|
||||
options.Driver = DefaultDesktopDriver
|
||||
}
|
||||
options.ConfigPath = "localControl.desktop.helperPath"
|
||||
options.DisabledMessage = "local desktop control is disabled; enable localControl.desktop.enabled in user config"
|
||||
options.MissingHint = "install cua-driver or set localControl.desktop.helperPath"
|
||||
return Desktop{helper: NewHelper(options)}
|
||||
}
|
||||
|
||||
func NewTerminal(options TerminalOptions) Terminal {
|
||||
if strings.TrimSpace(options.Driver) == "" {
|
||||
options.Driver = DefaultTerminalDriver
|
||||
}
|
||||
options.ConfigPath = "localControl.terminal.helperPath"
|
||||
options.DisabledMessage = "local terminal control is disabled by user config"
|
||||
options.MissingHint = "install the tuistory package or set localControl.terminal.helperPath"
|
||||
return Terminal{helper: NewHelper(options)}
|
||||
}
|
||||
|
||||
func (helper Helper) Enabled() bool {
|
||||
return helper.options.Enabled
|
||||
}
|
||||
|
||||
func (helper Helper) Run(ctx context.Context, args ...string) (CommandResult, error) {
|
||||
if !helper.options.Enabled {
|
||||
message := strings.TrimSpace(helper.options.DisabledMessage)
|
||||
if message == "" {
|
||||
message = "local control helper is disabled"
|
||||
}
|
||||
return CommandResult{}, errors.New(message)
|
||||
}
|
||||
resolved, err := helper.resolvedHelper()
|
||||
if err != nil {
|
||||
return CommandResult{}, err
|
||||
}
|
||||
runArgs := append([]string{}, resolved.PrefixArgs...)
|
||||
runArgs = append(runArgs, args...)
|
||||
return helper.options.Runner.Run(ctx, resolved.Command, runArgs, resolved.Env, helper.options.Timeout)
|
||||
}
|
||||
|
||||
func (helper Helper) resolvedHelper() (ResolvedHelper, error) {
|
||||
label := helper.helperLabel()
|
||||
if helperPath := strings.TrimSpace(helper.options.HelperPath); helperPath != "" {
|
||||
if !filepath.IsAbs(helperPath) {
|
||||
abs, err := filepath.Abs(helperPath)
|
||||
if err != nil {
|
||||
return ResolvedHelper{}, fmt.Errorf("resolve %s helper path: %w", label, err)
|
||||
}
|
||||
helperPath = abs
|
||||
}
|
||||
if err := validateHelperFile(label, helperPath); err != nil {
|
||||
return ResolvedHelper{}, err
|
||||
}
|
||||
return ResolvedHelper{Command: helperPath}, nil
|
||||
}
|
||||
|
||||
driver := strings.TrimSpace(helper.options.Driver)
|
||||
if driver == "" {
|
||||
driver = DefaultBrowserDriver
|
||||
}
|
||||
if resolved, ok, err := helperFromManifest(driver); ok || err != nil {
|
||||
return resolved, err
|
||||
}
|
||||
if resolved, ok, err := adjacentHelper(driver); ok || err != nil {
|
||||
return resolved, err
|
||||
}
|
||||
path, err := exec.LookPath(driver)
|
||||
if err != nil {
|
||||
hint := strings.TrimSpace(helper.options.MissingHint)
|
||||
if hint == "" {
|
||||
hint = "install it or configure the helper path"
|
||||
}
|
||||
return ResolvedHelper{}, fmt.Errorf("%s was not found on PATH; %s", driver, hint)
|
||||
}
|
||||
return ResolvedHelper{Command: path}, nil
|
||||
}
|
||||
|
||||
func (helper Helper) helperLabel() string {
|
||||
driver := strings.TrimSpace(helper.options.Driver)
|
||||
if driver == "" {
|
||||
return "local control"
|
||||
}
|
||||
return driver
|
||||
}
|
||||
|
||||
func (browser Browser) Enabled() bool {
|
||||
return browser.helper.Enabled()
|
||||
}
|
||||
|
||||
func (browser Browser) Run(ctx context.Context, args ...string) (CommandResult, error) {
|
||||
return browser.helper.Run(ctx, args...)
|
||||
}
|
||||
|
||||
func (desktop Desktop) Run(ctx context.Context, args ...string) (CommandResult, error) {
|
||||
return desktop.helper.Run(ctx, args...)
|
||||
}
|
||||
|
||||
func (desktop Desktop) Enabled() bool {
|
||||
return desktop.helper.Enabled()
|
||||
}
|
||||
|
||||
func (terminal Terminal) Run(ctx context.Context, args ...string) (CommandResult, error) {
|
||||
return terminal.helper.Run(ctx, args...)
|
||||
}
|
||||
|
||||
func (terminal Terminal) Enabled() bool {
|
||||
return terminal.helper.Enabled()
|
||||
}
|
||||
|
||||
type ExecRunner struct{}
|
||||
|
||||
var executablePath = os.Executable
|
||||
|
||||
func (ExecRunner) Run(ctx context.Context, path string, args []string, env []string, timeout time.Duration) (CommandResult, error) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if timeout <= 0 {
|
||||
timeout = DefaultTimeout
|
||||
}
|
||||
runCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
|
||||
cmd := exec.CommandContext(runCtx, path, args...)
|
||||
cmd.Env = mergeEnv(os.Environ(), env)
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
err := cmd.Run()
|
||||
result := CommandResult{
|
||||
Path: path,
|
||||
Args: append([]string(nil), args...),
|
||||
Stdout: stdout.String(),
|
||||
Stderr: stderr.String(),
|
||||
}
|
||||
if cmd.ProcessState != nil {
|
||||
result.ExitCode = cmd.ProcessState.ExitCode()
|
||||
} else if err != nil {
|
||||
result.ExitCode = -1
|
||||
}
|
||||
if errors.Is(runCtx.Err(), context.DeadlineExceeded) {
|
||||
return result, fmt.Errorf("local control helper timed out after %dms", timeout.Milliseconds())
|
||||
}
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
type helperManifest struct {
|
||||
Version int `json:"version"`
|
||||
Helpers map[string]manifestEntry `json:"helpers"`
|
||||
}
|
||||
|
||||
type manifestEntry struct {
|
||||
Command string `json:"command"`
|
||||
PrefixArgs []string `json:"prefixArgs"`
|
||||
PathPrepend []string `json:"pathPrepend"`
|
||||
Env map[string]string `json:"env"`
|
||||
}
|
||||
|
||||
func helperFromManifest(driver string) (ResolvedHelper, bool, error) {
|
||||
raw := strings.TrimSpace(os.Getenv(EnvHelperManifest))
|
||||
if raw == "" {
|
||||
return ResolvedHelper{}, false, nil
|
||||
}
|
||||
manifest, err := parseHelperManifest(raw)
|
||||
if err != nil {
|
||||
return ResolvedHelper{}, true, err
|
||||
}
|
||||
entry, ok := manifest.Helpers[driver]
|
||||
if !ok {
|
||||
return ResolvedHelper{}, false, nil
|
||||
}
|
||||
command := strings.TrimSpace(entry.Command)
|
||||
if command == "" {
|
||||
return ResolvedHelper{}, true, fmt.Errorf("%s entry in %s is missing command", driver, EnvHelperManifest)
|
||||
}
|
||||
if filepath.IsAbs(command) {
|
||||
if err := validateHelperFile(driver, command); err != nil {
|
||||
return ResolvedHelper{}, true, err
|
||||
}
|
||||
}
|
||||
return ResolvedHelper{
|
||||
Command: command,
|
||||
PrefixArgs: cleanStringSlice(entry.PrefixArgs),
|
||||
Env: manifestEnv(entry),
|
||||
}, true, nil
|
||||
}
|
||||
|
||||
func parseHelperManifest(raw string) (helperManifest, error) {
|
||||
var manifest helperManifest
|
||||
if err := json.Unmarshal([]byte(raw), &manifest); err != nil {
|
||||
return helperManifest{}, fmt.Errorf("invalid %s JSON: %w", EnvHelperManifest, err)
|
||||
}
|
||||
if manifest.Helpers == nil {
|
||||
manifest.Helpers = map[string]manifestEntry{}
|
||||
}
|
||||
return manifest, nil
|
||||
}
|
||||
|
||||
func validateHelperFile(label string, path string) error {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s helper not found at %s: %w", label, path, err)
|
||||
}
|
||||
if info.IsDir() {
|
||||
return fmt.Errorf("%s helper path is a directory: %s", label, path)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func adjacentHelper(driver string) (ResolvedHelper, bool, error) {
|
||||
executable, err := executablePath()
|
||||
if err != nil {
|
||||
return ResolvedHelper{}, false, nil
|
||||
}
|
||||
executableDir := filepath.Dir(executable)
|
||||
for _, dir := range []string{
|
||||
executableDir,
|
||||
filepath.Join(executableDir, "helpers"),
|
||||
filepath.Join(executableDir, "helpers", "node_modules", ".bin"),
|
||||
filepath.Join(executableDir, "node_modules", ".bin"),
|
||||
} {
|
||||
for _, name := range helperExecutableNames(driver) {
|
||||
path := filepath.Join(dir, name)
|
||||
if err := validateHelperFile(driver, path); err == nil {
|
||||
return helperCommandForPath(path), true, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return ResolvedHelper{}, false, nil
|
||||
}
|
||||
|
||||
func helperExecutableNames(driver string) []string {
|
||||
if runtime.GOOS == "windows" {
|
||||
return []string{driver + ".cmd", driver + ".exe", driver}
|
||||
}
|
||||
return []string{driver}
|
||||
}
|
||||
|
||||
func helperCommandForPath(path string) ResolvedHelper {
|
||||
if runtime.GOOS == "windows" && strings.EqualFold(filepath.Ext(path), ".cmd") {
|
||||
command := os.Getenv("ComSpec")
|
||||
if strings.TrimSpace(command) == "" {
|
||||
command = "cmd.exe"
|
||||
}
|
||||
return ResolvedHelper{
|
||||
Command: command,
|
||||
PrefixArgs: []string{"/d", "/s", "/c", `"` + strings.ReplaceAll(path, `"`, `""`) + `"`},
|
||||
}
|
||||
}
|
||||
return ResolvedHelper{Command: path}
|
||||
}
|
||||
|
||||
func manifestEnv(entry manifestEntry) []string {
|
||||
env := map[string]string{}
|
||||
for key, value := range entry.Env {
|
||||
key = strings.TrimSpace(key)
|
||||
if key != "" {
|
||||
env[key] = value
|
||||
}
|
||||
}
|
||||
pathPrepend := cleanStringSlice(entry.PathPrepend)
|
||||
if len(pathPrepend) > 0 {
|
||||
basePath := env["PATH"]
|
||||
if basePath == "" {
|
||||
basePath = os.Getenv("PATH")
|
||||
}
|
||||
prefix := strings.Join(pathPrepend, string(os.PathListSeparator))
|
||||
if basePath != "" {
|
||||
env["PATH"] = prefix + string(os.PathListSeparator) + basePath
|
||||
} else {
|
||||
env["PATH"] = prefix
|
||||
}
|
||||
}
|
||||
return envMapToList(env)
|
||||
}
|
||||
|
||||
func normalizeEnvKey(key string) string {
|
||||
if runtime.GOOS == "windows" {
|
||||
return strings.ToUpper(key)
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
func mergeEnv(base []string, overlay []string) []string {
|
||||
if len(overlay) == 0 {
|
||||
return base
|
||||
}
|
||||
merged := append([]string{}, base...)
|
||||
index := map[string]int{}
|
||||
for i, item := range merged {
|
||||
if key, _, ok := strings.Cut(item, "="); ok {
|
||||
index[normalizeEnvKey(key)] = i
|
||||
}
|
||||
}
|
||||
for _, item := range overlay {
|
||||
key, _, ok := strings.Cut(item, "=")
|
||||
if !ok || key == "" {
|
||||
continue
|
||||
}
|
||||
normalized := normalizeEnvKey(key)
|
||||
if existing, ok := index[normalized]; ok {
|
||||
merged[existing] = item
|
||||
} else {
|
||||
index[normalized] = len(merged)
|
||||
merged = append(merged, item)
|
||||
}
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
func cleanStringSlice(values []string) []string {
|
||||
cleaned := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
value = strings.TrimSpace(value)
|
||||
if value != "" {
|
||||
cleaned = append(cleaned, value)
|
||||
}
|
||||
}
|
||||
return cleaned
|
||||
}
|
||||
|
||||
func envMapToList(env map[string]string) []string {
|
||||
if len(env) == 0 {
|
||||
return nil
|
||||
}
|
||||
keys := make([]string, 0, len(env))
|
||||
for key := range env {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
values := make([]string, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
values = append(values, key+"="+env[key])
|
||||
}
|
||||
return values
|
||||
}
|
||||
@@ -1,237 +0,0 @@
|
||||
package localcontrol
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type fakeCommandRunner struct {
|
||||
path string
|
||||
args []string
|
||||
env []string
|
||||
}
|
||||
|
||||
func (runner *fakeCommandRunner) Run(_ context.Context, path string, args []string, env []string, _ time.Duration) (CommandResult, error) {
|
||||
runner.path = path
|
||||
runner.args = append([]string(nil), args...)
|
||||
runner.env = append([]string(nil), env...)
|
||||
return CommandResult{
|
||||
Path: path,
|
||||
Args: append([]string(nil), args...),
|
||||
Stdout: "ok\n",
|
||||
ExitCode: 0,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func TestBrowserRunUsesConfiguredHelperPath(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
helper := filepath.Join(dir, "agent-browser")
|
||||
if err := os.WriteFile(helper, []byte("#!/bin/sh\n"), 0o755); err != nil {
|
||||
t.Fatalf("write helper: %v", err)
|
||||
}
|
||||
runner := &fakeCommandRunner{}
|
||||
browser := NewBrowser(BrowserOptions{
|
||||
Enabled: true,
|
||||
HelperPath: helper,
|
||||
Runner: runner,
|
||||
})
|
||||
|
||||
result, err := browser.Run(context.Background(), "open", "https://example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("Run returned error: %v", err)
|
||||
}
|
||||
if result.Output() != "ok" {
|
||||
t.Fatalf("Output = %q, want ok", result.Output())
|
||||
}
|
||||
if runner.path != helper {
|
||||
t.Fatalf("runner path = %q, want %q", runner.path, helper)
|
||||
}
|
||||
if want := []string{"open", "https://example.com"}; !reflect.DeepEqual(runner.args, want) {
|
||||
t.Fatalf("runner args = %#v, want %#v", runner.args, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBrowserRunDisabledFailsBeforeDiscovery(t *testing.T) {
|
||||
browser := NewBrowser(BrowserOptions{Enabled: false, HelperPath: "/does/not/exist"})
|
||||
_, err := browser.Run(context.Background(), "snapshot")
|
||||
if err == nil || !strings.Contains(err.Error(), "disabled") {
|
||||
t.Fatalf("Run error = %v, want disabled", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBrowserRunMissingHelperPathIsActionable(t *testing.T) {
|
||||
browser := NewBrowser(BrowserOptions{Enabled: true, HelperPath: filepath.Join(t.TempDir(), "missing")})
|
||||
_, err := browser.Run(context.Background(), "snapshot")
|
||||
if err == nil || !strings.Contains(err.Error(), "helper not found") {
|
||||
t.Fatalf("Run error = %v, want missing helper", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBrowserRunUsesHelperManifest(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
binDir := filepath.Join(dir, "node_modules", ".bin")
|
||||
if err := os.MkdirAll(binDir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir bin: %v", err)
|
||||
}
|
||||
helper := filepath.Join(binDir, "agent-browser")
|
||||
if err := os.WriteFile(helper, []byte("#!/bin/sh\n"), 0o755); err != nil {
|
||||
t.Fatalf("write helper: %v", err)
|
||||
}
|
||||
t.Setenv(EnvHelperManifest, `{"version":1,"helpers":{"agent-browser":{"command":`+quoteJSON(helper)+`,"pathPrepend":[`+quoteJSON(binDir)+`],"env":{"ZERO_HELPER_TEST":"1"}}}}`)
|
||||
|
||||
runner := &fakeCommandRunner{}
|
||||
browser := NewBrowser(BrowserOptions{
|
||||
Enabled: true,
|
||||
Runner: runner,
|
||||
})
|
||||
|
||||
if _, err := browser.Run(context.Background(), "snapshot"); err != nil {
|
||||
t.Fatalf("Run returned error: %v", err)
|
||||
}
|
||||
if runner.path != helper {
|
||||
t.Fatalf("runner path = %q, want manifest helper %q", runner.path, helper)
|
||||
}
|
||||
if !envContains(runner.env, "ZERO_HELPER_TEST=1") {
|
||||
t.Fatalf("env = %#v, want ZERO_HELPER_TEST", runner.env)
|
||||
}
|
||||
pathValue := envValue(runner.env, "PATH")
|
||||
if !strings.HasPrefix(pathValue, binDir+string(os.PathListSeparator)) && pathValue != binDir {
|
||||
t.Fatalf("PATH overlay = %q, want prefix %q", pathValue, binDir)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBrowserRunUsesManifestPrefixArgs(t *testing.T) {
|
||||
runner := &fakeCommandRunner{}
|
||||
t.Setenv(EnvHelperManifest, `{"version":1,"helpers":{"agent-browser":{"command":"cmd.exe","prefixArgs":["/d","/s","/c","C:\\zero\\agent-browser.cmd"]}}}`)
|
||||
browser := NewBrowser(BrowserOptions{
|
||||
Enabled: true,
|
||||
Runner: runner,
|
||||
})
|
||||
|
||||
if _, err := browser.Run(context.Background(), "open", "https://example.com"); err != nil {
|
||||
t.Fatalf("Run returned error: %v", err)
|
||||
}
|
||||
if runner.path != "cmd.exe" {
|
||||
t.Fatalf("runner path = %q, want cmd.exe", runner.path)
|
||||
}
|
||||
want := []string{"/d", "/s", "/c", `C:\zero\agent-browser.cmd`, "open", "https://example.com"}
|
||||
if !reflect.DeepEqual(runner.args, want) {
|
||||
t.Fatalf("runner args = %#v, want %#v", runner.args, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeEnvReplacesPathCaseInsensitivelyOnWindows(t *testing.T) {
|
||||
if runtime.GOOS != "windows" {
|
||||
t.Skip("Windows env keys are case-insensitive")
|
||||
}
|
||||
env := mergeEnv([]string{`Path=C:\Windows`, "ZERO=1"}, []string{`PATH=C:\Zero`})
|
||||
pathCount := 0
|
||||
for _, item := range env {
|
||||
key, value, ok := strings.Cut(item, "=")
|
||||
if !ok || !strings.EqualFold(key, "PATH") {
|
||||
continue
|
||||
}
|
||||
pathCount++
|
||||
if value != `C:\Zero` {
|
||||
t.Fatalf("PATH value = %q, want C:\\Zero", value)
|
||||
}
|
||||
}
|
||||
if pathCount != 1 {
|
||||
t.Fatalf("PATH entries = %d in %#v, want 1", pathCount, env)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBrowserRunUsesAdjacentPackagedHelper(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
native := filepath.Join(root, "zero")
|
||||
if err := os.WriteFile(native, []byte("#!/bin/sh\n"), 0o755); err != nil {
|
||||
t.Fatalf("write native: %v", err)
|
||||
}
|
||||
helpersDir := filepath.Join(root, "helpers")
|
||||
if err := os.MkdirAll(helpersDir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir helpers: %v", err)
|
||||
}
|
||||
helper := filepath.Join(helpersDir, "agent-browser")
|
||||
if err := os.WriteFile(helper, []byte("#!/bin/sh\n"), 0o755); err != nil {
|
||||
t.Fatalf("write helper: %v", err)
|
||||
}
|
||||
oldExecutablePath := executablePath
|
||||
executablePath = func() (string, error) { return native, nil }
|
||||
t.Cleanup(func() { executablePath = oldExecutablePath })
|
||||
|
||||
runner := &fakeCommandRunner{}
|
||||
browser := NewBrowser(BrowserOptions{
|
||||
Enabled: true,
|
||||
Runner: runner,
|
||||
})
|
||||
if _, err := browser.Run(context.Background(), "snapshot"); err != nil {
|
||||
t.Fatalf("Run returned error: %v", err)
|
||||
}
|
||||
if runner.path != helper {
|
||||
t.Fatalf("runner path = %q, want adjacent helper %q", runner.path, helper)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBrowserRunUsesAdjacentPackagedNodeBinHelper(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
native := filepath.Join(root, "zero")
|
||||
if err := os.WriteFile(native, []byte("#!/bin/sh\n"), 0o755); err != nil {
|
||||
t.Fatalf("write native: %v", err)
|
||||
}
|
||||
binDir := filepath.Join(root, "helpers", "node_modules", ".bin")
|
||||
if err := os.MkdirAll(binDir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir helper bin: %v", err)
|
||||
}
|
||||
helper := filepath.Join(binDir, "agent-browser")
|
||||
if err := os.WriteFile(helper, []byte("#!/bin/sh\n"), 0o755); err != nil {
|
||||
t.Fatalf("write helper: %v", err)
|
||||
}
|
||||
oldExecutablePath := executablePath
|
||||
executablePath = func() (string, error) { return native, nil }
|
||||
t.Cleanup(func() { executablePath = oldExecutablePath })
|
||||
|
||||
runner := &fakeCommandRunner{}
|
||||
browser := NewBrowser(BrowserOptions{
|
||||
Enabled: true,
|
||||
Runner: runner,
|
||||
})
|
||||
if _, err := browser.Run(context.Background(), "snapshot"); err != nil {
|
||||
t.Fatalf("Run returned error: %v", err)
|
||||
}
|
||||
if runner.path != helper {
|
||||
t.Fatalf("runner path = %q, want packaged node bin helper %q", runner.path, helper)
|
||||
}
|
||||
}
|
||||
|
||||
func quoteJSON(value string) string {
|
||||
data, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
|
||||
func envContains(env []string, want string) bool {
|
||||
for _, item := range env {
|
||||
if item == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func envValue(env []string, key string) string {
|
||||
for _, item := range env {
|
||||
if got, value, ok := strings.Cut(item, "="); ok && got == key {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -1,33 +1,9 @@
|
||||
package modelregistry
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
import "fmt"
|
||||
|
||||
const DefaultModelID = "gpt-4.1"
|
||||
|
||||
// FallbackContextWindow is the assumed context window (max input tokens) for a
|
||||
// model that isn't in the curated registry and wasn't resolved from live provider
|
||||
// discovery — e.g. proxy/custom models like the GPT-5 Codex, xAI, or Ollama-cloud
|
||||
// variants. A positive value here is what enables agent-loop compaction (both the
|
||||
// proactive ~80% trigger and the reactive post-overflow recovery) for those models;
|
||||
// without it compaction stays disabled and long sessions overflow. It is only a
|
||||
// backstop: an exact window from the registry or live discovery always wins.
|
||||
const FallbackContextWindow = 200_000
|
||||
|
||||
// AgentContextWindow turns a resolved context window into the value used to enable
|
||||
// agent-loop compaction: the resolved window when known, else FallbackContextWindow
|
||||
// so compaction is enabled even for uncatalogued models. Kept separate from the
|
||||
// raw resolver so display gauges still show nothing (not a guessed denominator)
|
||||
// when the real window is unknown.
|
||||
func AgentContextWindow(resolved int) int {
|
||||
if resolved <= 0 {
|
||||
return FallbackContextWindow
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
const sourceLastVerified = "2026-06-04"
|
||||
|
||||
const (
|
||||
@@ -193,44 +169,11 @@ func (registry Registry) SupportsCapability(pattern string, capability ModelCapa
|
||||
}
|
||||
|
||||
func (registry Registry) ReasoningEfforts(pattern string) []ReasoningEffort {
|
||||
if model, ok := registry.Get(pattern); ok {
|
||||
return append([]ReasoningEffort{}, effectiveReasoningEfforts(model)...)
|
||||
}
|
||||
// Unknown model not in the curated catalog — e.g. a GPT-5 / Codex / o-series
|
||||
// variant served via the ChatGPT proxy or a custom OpenAI-compatible endpoint.
|
||||
// Infer from the name so /effort still shows controls for it.
|
||||
return reasoningEffortsForModelName(pattern)
|
||||
}
|
||||
|
||||
// reasoningEffortsForModelName infers reasoning-effort controls from a model name
|
||||
// for known reasoning families not present in the catalog. It returns nil for
|
||||
// non-reasoning models (e.g. GPT-4.1 / GPT-4o), so /effort stays empty there.
|
||||
func reasoningEffortsForModelName(name string) []ReasoningEffort {
|
||||
n := strings.ToLower(strings.TrimSpace(name))
|
||||
if i := strings.IndexByte(n, ':'); i >= 0 { // drop a "provider:" qualifier
|
||||
n = n[i+1:]
|
||||
}
|
||||
switch {
|
||||
case strings.HasPrefix(n, "gpt-5") || strings.HasPrefix(n, "gpt5"):
|
||||
// GPT-5 / Codex (gpt-5.x) add a "minimal" tier below low.
|
||||
return []ReasoningEffort{ReasoningEffortMinimal, ReasoningEffortLow, ReasoningEffortMedium, ReasoningEffortHigh}
|
||||
case strings.Contains(n, "codex") || isOSeriesModelName(n):
|
||||
return []ReasoningEffort{ReasoningEffortLow, ReasoningEffortMedium, ReasoningEffortHigh}
|
||||
default:
|
||||
model, ok := registry.Get(pattern)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// isOSeriesModelName reports whether name is an OpenAI o-series reasoning model
|
||||
// (o1/o3/o4, optionally with a -mini/-pro suffix), without matching unrelated
|
||||
// names that merely start with the same letters.
|
||||
func isOSeriesModelName(n string) bool {
|
||||
for _, prefix := range []string{"o1", "o3", "o4"} {
|
||||
if n == prefix || strings.HasPrefix(n, prefix+"-") || strings.HasPrefix(n, prefix+"mini") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
return append([]ReasoningEffort{}, model.ReasoningEfforts...)
|
||||
}
|
||||
|
||||
func (registry Registry) RequireProvider(pattern string, provider ProviderKind) (ModelEntry, error) {
|
||||
|
||||
@@ -92,10 +92,11 @@ func CalculateCost(model ModelEntry, usage zeroruntime.Usage) (CostBreakdown, er
|
||||
if uncachedInputTokens < 0 {
|
||||
uncachedInputTokens = 0
|
||||
}
|
||||
billableOutputTokens := outputTokens + reasoningTokens
|
||||
inputCost := costForTokens(uncachedInputTokens, inputRate)
|
||||
cachedInputCost := costForTokens(cachedInputTokens, cachedRate)
|
||||
cacheWriteCost := costForTokens(cacheWriteTokens, cacheWriteRate)
|
||||
outputCost := costForTokens(outputTokens, outputRate)
|
||||
outputCost := costForTokens(billableOutputTokens, outputRate)
|
||||
|
||||
breakdown := CostBreakdown{
|
||||
ModelID: model.ID,
|
||||
|
||||
@@ -94,33 +94,6 @@ func TestRegistryCostIgnoresCachedInputWithoutCachePricing(t *testing.T) {
|
||||
assertClose(t, cost.OutputCost, 0.03)
|
||||
}
|
||||
|
||||
func TestRegistryCostTreatsReasoningAsOutputBreakdown(t *testing.T) {
|
||||
registry, err := DefaultRegistry()
|
||||
if err != nil {
|
||||
t.Fatalf("DefaultRegistry returned error: %v", err)
|
||||
}
|
||||
|
||||
withReasoning, err := registry.EstimateCost("gpt-4.1", zeroruntime.Usage{
|
||||
InputTokens: 1_000,
|
||||
OutputTokens: 1_000,
|
||||
ReasoningTokens: 400,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("EstimateCost(withReasoning) returned error: %v", err)
|
||||
}
|
||||
plain, err := registry.EstimateCost("gpt-4.1", zeroruntime.Usage{
|
||||
InputTokens: 1_000,
|
||||
OutputTokens: 1_000,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("EstimateCost(plain) returned error: %v", err)
|
||||
}
|
||||
|
||||
if withReasoning.OutputCost != plain.OutputCost || withReasoning.TotalCost != plain.TotalCost {
|
||||
t.Fatalf("reasoning should be a breakdown of output cost, with=%#v plain=%#v", withReasoning, plain)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryCostSelectsTierForLongContextPricing(t *testing.T) {
|
||||
registry, err := DefaultRegistry()
|
||||
if err != nil {
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
package modelregistry
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestReasoningEffortsFallbackForGPT5AndOSeries(t *testing.T) {
|
||||
reg, err := DefaultRegistry()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
want []ReasoningEffort
|
||||
}{
|
||||
{"gpt-5", []ReasoningEffort{ReasoningEffortMinimal, ReasoningEffortLow, ReasoningEffortMedium, ReasoningEffortHigh}},
|
||||
{"gpt-5.5", []ReasoningEffort{ReasoningEffortMinimal, ReasoningEffortLow, ReasoningEffortMedium, ReasoningEffortHigh}},
|
||||
{"gpt-5.4-mini", []ReasoningEffort{ReasoningEffortMinimal, ReasoningEffortLow, ReasoningEffortMedium, ReasoningEffortHigh}},
|
||||
{"o3-mini", []ReasoningEffort{ReasoningEffortLow, ReasoningEffortMedium, ReasoningEffortHigh}},
|
||||
{"gpt-5.3-codex-spark", []ReasoningEffort{ReasoningEffortMinimal, ReasoningEffortLow, ReasoningEffortMedium, ReasoningEffortHigh}},
|
||||
{"gpt-4.1", nil}, // non-reasoning, registered: stays empty
|
||||
{"gpt-4o-mini", nil},
|
||||
{"ollama/llama3.1", nil},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := reg.ReasoningEfforts(c.name)
|
||||
if len(got) != len(c.want) {
|
||||
t.Fatalf("%s: got %v, want %v", c.name, got, c.want)
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != c.want[i] {
|
||||
t.Fatalf("%s: got %v, want %v", c.name, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -47,13 +47,10 @@ func (registry Registry) ResolveWithFallback(input string) (ModelEntry, string,
|
||||
|
||||
// EffectiveReasoningEffort returns the effort to use for a model: the requested
|
||||
// value if the model supports it, otherwise the model's default (or first
|
||||
// supported, or none). It resolves the supported set through
|
||||
// effectiveReasoningEfforts so it sees the same name-based fallback the /effort
|
||||
// picker uses — the two must never disagree about which tiers a model supports.
|
||||
// supported, or none).
|
||||
func EffectiveReasoningEffort(model ModelEntry, requested ReasoningEffort) ReasoningEffort {
|
||||
efforts := effectiveReasoningEfforts(model)
|
||||
if requested != "" {
|
||||
for _, effort := range efforts {
|
||||
for _, effort := range model.ReasoningEfforts {
|
||||
if effort == requested {
|
||||
return requested
|
||||
}
|
||||
@@ -62,23 +59,8 @@ func EffectiveReasoningEffort(model ModelEntry, requested ReasoningEffort) Reaso
|
||||
if model.DefaultReasoningEffort != "" {
|
||||
return model.DefaultReasoningEffort
|
||||
}
|
||||
if len(efforts) > 0 {
|
||||
return efforts[0]
|
||||
if len(model.ReasoningEfforts) > 0 {
|
||||
return model.ReasoningEfforts[0]
|
||||
}
|
||||
return ReasoningEffortNone
|
||||
}
|
||||
|
||||
// effectiveReasoningEfforts returns a model's supported reasoning efforts, falling
|
||||
// back to name-based inference (reasoningEffortsForModelName) when the catalog
|
||||
// entry enumerates none. Both the /effort picker (Registry.ReasoningEfforts) and
|
||||
// the run-time resolver (EffectiveReasoningEffort) read efforts through this
|
||||
// single helper, so the picker can never advertise a tier the resolver drops.
|
||||
func effectiveReasoningEfforts(model ModelEntry) []ReasoningEffort {
|
||||
if len(model.ReasoningEfforts) > 0 {
|
||||
return model.ReasoningEfforts
|
||||
}
|
||||
if efforts := reasoningEffortsForModelName(model.ID); len(efforts) > 0 {
|
||||
return efforts
|
||||
}
|
||||
return reasoningEffortsForModelName(model.APIModel)
|
||||
}
|
||||
@@ -79,43 +79,3 @@ func TestEffectiveReasoningEffort(t *testing.T) {
|
||||
t.Errorf("empty effort should use default low, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEffectiveReasoningEffortUsesNameFallback pins that the run-time resolver
|
||||
// honors the same name-based fallback the /effort picker uses. A model that the
|
||||
// catalog enumerates with no efforts but whose name is a known reasoning family
|
||||
// (e.g. a GPT-5 variant served via a proxy) must have its requested effort
|
||||
// honored — not silently coerced to "none" while the picker advertises controls.
|
||||
func TestEffectiveReasoningEffortUsesNameFallback(t *testing.T) {
|
||||
// Registered model, no explicit efforts, GPT-5 api model -> fallback infers
|
||||
// {minimal, low, medium, high}.
|
||||
gpt5 := ModelEntry{ID: "gpt-5-proxy", APIModel: "gpt-5", Provider: ProviderOpenAI}
|
||||
if got := EffectiveReasoningEffort(gpt5, ReasoningEffortHigh); got != ReasoningEffortHigh {
|
||||
t.Errorf("supported (via name fallback) effort = %q; want high", got)
|
||||
}
|
||||
if got := EffectiveReasoningEffort(gpt5, ReasoningEffortMinimal); got != ReasoningEffortMinimal {
|
||||
t.Errorf("minimal (via name fallback) = %q; want minimal", got)
|
||||
}
|
||||
// xhigh is outside the inferred set and there is no declared default, so it
|
||||
// coerces to the first inferred tier rather than to "none".
|
||||
if got := EffectiveReasoningEffort(gpt5, ReasoningEffortXHigh); got != ReasoningEffortMinimal {
|
||||
t.Errorf("unsupported effort on a fallback model = %q; want minimal (first inferred)", got)
|
||||
}
|
||||
|
||||
// Non-reasoning model: name matches nothing, stays "none".
|
||||
gpt4o := ModelEntry{ID: "gpt-4o", APIModel: "gpt-4o", Provider: ProviderOpenAI}
|
||||
if got := EffectiveReasoningEffort(gpt4o, ReasoningEffortHigh); got != ReasoningEffortNone {
|
||||
t.Errorf("non-reasoning model = %q; want none", got)
|
||||
}
|
||||
|
||||
// The picker and the resolver must agree on the supported set for the same id.
|
||||
reg := resolveTestRegistry(t)
|
||||
picker := reg.ReasoningEfforts("gpt-5") // unknown -> name fallback
|
||||
if len(picker) == 0 {
|
||||
t.Fatal("picker should advertise efforts for a gpt-5 name")
|
||||
}
|
||||
for _, tier := range picker {
|
||||
if got := EffectiveReasoningEffort(gpt5, tier); got != tier {
|
||||
t.Errorf("picker advertises %q but resolver returns %q", tier, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,6 @@ func TestPackageBinPointsToNodeWrapper(t *testing.T) {
|
||||
Scripts map[string]string `json:"scripts"`
|
||||
License string `json:"license"`
|
||||
Files []string `json:"files"`
|
||||
Deps map[string]string `json:"dependencies"`
|
||||
Repository json.RawMessage `json:"repository"`
|
||||
Engines map[string]string `json:"engines"`
|
||||
}
|
||||
@@ -61,11 +60,6 @@ func TestPackageBinPointsToNodeWrapper(t *testing.T) {
|
||||
if pkg.Engines["node"] == "" {
|
||||
t.Fatalf("package.json engines.node is empty; the wrapper and installer require a modern Node")
|
||||
}
|
||||
for _, name := range []string{"agent-browser", "tuistory"} {
|
||||
if pkg.Deps[name] == "" {
|
||||
t.Fatalf("package.json dependencies is missing %q", name)
|
||||
}
|
||||
}
|
||||
wantFiles := map[string]bool{"bin/zero.js": false, "scripts/postinstall.mjs": false}
|
||||
for _, f := range pkg.Files {
|
||||
if _, ok := wantFiles[f]; ok {
|
||||
@@ -224,7 +218,6 @@ func TestNodeWrapperReportsMissingNativeBinary(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), nodeWrapperTimeout())
|
||||
defer cancel()
|
||||
command := nodeWrapperCommand(ctx, node, wrapperPath, "--version")
|
||||
command.Env = append(withoutEnvKey(command.Env, "ZERO_LOCAL_CONTROL_HELPERS"), "ZERO_LOCAL_CONTROL_HELPERS=")
|
||||
output, err := command.CombinedOutput()
|
||||
if ctx.Err() != nil {
|
||||
t.Fatalf("wrapper timed out reporting missing native binary: %v; output: %s", ctx.Err(), output)
|
||||
@@ -268,116 +261,6 @@ func TestNodeWrapperLaunchesNativeBinary(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeWrapperPassesLocalControlHelperManifest(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("mock executable fixture uses a POSIX shell script")
|
||||
}
|
||||
node := requireNode(t)
|
||||
wrapperPath := copyWrapperFixture(t)
|
||||
root := filepath.Dir(filepath.Dir(wrapperPath))
|
||||
nativePath := filepath.Join(root, "zero")
|
||||
if err := os.WriteFile(nativePath, []byte("#!/usr/bin/env sh\nprintf '%s\\n' \"$ZERO_LOCAL_CONTROL_HELPERS\"\n"), 0o755); err != nil {
|
||||
t.Fatalf("WriteFile native fixture: %v", err)
|
||||
}
|
||||
binDir := filepath.Join(root, "node_modules", ".bin")
|
||||
if err := os.MkdirAll(binDir, 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll node_modules/.bin: %v", err)
|
||||
}
|
||||
for _, name := range []string{"agent-browser", "tuistory"} {
|
||||
if err := os.WriteFile(filepath.Join(binDir, name), []byte("#!/usr/bin/env sh\n"), 0o755); err != nil {
|
||||
t.Fatalf("WriteFile helper %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), nodeWrapperTimeout())
|
||||
defer cancel()
|
||||
command := nodeWrapperCommand(ctx, node, wrapperPath, "--version")
|
||||
output, err := command.CombinedOutput()
|
||||
if ctx.Err() != nil {
|
||||
t.Fatalf("wrapper timed out launching native binary: %v; output: %s", ctx.Err(), output)
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("wrapper returned error: %v; output: %s", err, output)
|
||||
}
|
||||
var manifest struct {
|
||||
Version int `json:"version"`
|
||||
Helpers map[string]struct {
|
||||
Command string `json:"command"`
|
||||
PrefixArgs []string `json:"prefixArgs"`
|
||||
PathPrepend []string `json:"pathPrepend"`
|
||||
} `json:"helpers"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(strings.TrimSpace(string(output))), &manifest); err != nil {
|
||||
t.Fatalf("manifest JSON = %q: %v", output, err)
|
||||
}
|
||||
if manifest.Version != 1 {
|
||||
t.Fatalf("manifest version = %d, want 1", manifest.Version)
|
||||
}
|
||||
for _, name := range []string{"agent-browser", "tuistory"} {
|
||||
helper, ok := manifest.Helpers[name]
|
||||
if !ok {
|
||||
t.Fatalf("manifest missing helper %q: %#v", name, manifest.Helpers)
|
||||
}
|
||||
wantCommand := canonicalTestPath(t, filepath.Join(binDir, name))
|
||||
if helper.Command != wantCommand {
|
||||
t.Fatalf("%s command = %q, want %q", name, helper.Command, wantCommand)
|
||||
}
|
||||
wantBinDir := canonicalTestPath(t, binDir)
|
||||
if len(helper.PathPrepend) != 1 || helper.PathPrepend[0] != wantBinDir {
|
||||
t.Fatalf("%s pathPrepend = %#v, want [%q]", name, helper.PathPrepend, wantBinDir)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeWrapperClearsInheritedLocalControlHelperManifestWhenNoHelpers(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("mock executable fixture uses a POSIX shell script")
|
||||
}
|
||||
node := requireNode(t)
|
||||
wrapperPath := copyWrapperFixture(t)
|
||||
root := filepath.Dir(filepath.Dir(wrapperPath))
|
||||
nativePath := filepath.Join(root, "zero")
|
||||
if err := os.WriteFile(nativePath, []byte("#!/usr/bin/env sh\nif [ -n \"${ZERO_LOCAL_CONTROL_HELPERS+x}\" ]; then printf 'set\\n'; else printf 'unset\\n'; fi\n"), 0o755); err != nil {
|
||||
t.Fatalf("WriteFile native fixture: %v", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), nodeWrapperTimeout())
|
||||
defer cancel()
|
||||
command := nodeWrapperCommand(ctx, node, wrapperPath, "--version")
|
||||
command.Env = append(withoutEnvKey(command.Env, "ZERO_LOCAL_CONTROL_HELPERS"), `ZERO_LOCAL_CONTROL_HELPERS={"version":1,"helpers":{"agent-browser":{"command":"stale"}}}`)
|
||||
output, err := command.CombinedOutput()
|
||||
if ctx.Err() != nil {
|
||||
t.Fatalf("wrapper timed out launching native binary: %v; output: %s", ctx.Err(), output)
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("wrapper returned error: %v; output: %s", err, output)
|
||||
}
|
||||
if got := strings.TrimSpace(string(output)); got != "unset" {
|
||||
t.Fatalf("ZERO_LOCAL_CONTROL_HELPERS state = %q, want unset", got)
|
||||
}
|
||||
}
|
||||
|
||||
func canonicalTestPath(t *testing.T, path string) string {
|
||||
t.Helper()
|
||||
realPath, err := filepath.EvalSymlinks(path)
|
||||
if err != nil {
|
||||
t.Fatalf("EvalSymlinks %s: %v", path, err)
|
||||
}
|
||||
return realPath
|
||||
}
|
||||
|
||||
func withoutEnvKey(env []string, key string) []string {
|
||||
prefix := key + "="
|
||||
out := make([]string, 0, len(env))
|
||||
for _, item := range env {
|
||||
if strings.HasPrefix(item, prefix) {
|
||||
continue
|
||||
}
|
||||
out = append(out, item)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func copyWrapperFixture(t *testing.T) string {
|
||||
t.Helper()
|
||||
root := repoRoot(t)
|
||||
|
||||
@@ -104,7 +104,7 @@ var descriptors = []Descriptor{
|
||||
// way in; RequiresAuth is forced true so catalog consumers know the provider
|
||||
// needs an interactive login before a request will succeed.
|
||||
func() Descriptor {
|
||||
d := openAICompat("chatgpt", "ChatGPT", "https://chatgpt.com/backend-api/codex", "gpt-5.5", nil)
|
||||
d := openAICompat("chatgpt", "ChatGPT", "https://chatgpt.com/backend-api/codex", "gpt-5", nil)
|
||||
d.RequiresAuth = true
|
||||
return oauthProvider(d, false, false)
|
||||
}(),
|
||||
|
||||
@@ -56,12 +56,6 @@ var curatedModels = map[string][]Model{
|
||||
{ID: "minimax/minimax-m2.1", Description: "agentic coding model"},
|
||||
{ID: "deepseek/deepseek-chat", Description: "coding model"},
|
||||
},
|
||||
"chatgpt": {
|
||||
{ID: "gpt-5.5", Description: "recommended Codex model"},
|
||||
{ID: "gpt-5.4", Description: "strong Codex model"},
|
||||
{ID: "gpt-5.4-mini", Description: "fast Codex model"},
|
||||
{ID: "gpt-5.3-codex-spark", Description: "research preview fast Codex model"},
|
||||
},
|
||||
"deepseek": {
|
||||
{ID: "deepseek-chat", Description: "catalog default"},
|
||||
{ID: "deepseek-reasoner", Description: "reasoning model"},
|
||||
|
||||
@@ -27,11 +27,6 @@ func TestModelsAreProviderScoped(t *testing.T) {
|
||||
want: []string{"llama-3.3-70b-versatile", "openai/gpt-oss-120b"},
|
||||
notWant: []string{"gpt-4.1", "claude-sonnet-4.5"},
|
||||
},
|
||||
{
|
||||
provider: "chatgpt",
|
||||
want: []string{"gpt-5.5", "gpt-5.4", "gpt-5.4-mini", "gpt-5.3-codex-spark"},
|
||||
notWant: []string{"gpt-5", "gpt-4.1", "openai/gpt-4.1"},
|
||||
},
|
||||
{
|
||||
provider: "mistral",
|
||||
want: []string{"mistral-large-latest", "codestral-latest"},
|
||||
|
||||
@@ -212,8 +212,8 @@ func (provider *Provider) emitPayload(ctx context.Context, data string, state *s
|
||||
}
|
||||
if payload.UsageMetadata != nil {
|
||||
state.inputTokens = payload.UsageMetadata.PromptTokenCount
|
||||
state.outputTokens = payload.UsageMetadata.CandidatesTokenCount
|
||||
state.reasoningTokens = payload.UsageMetadata.ThoughtsTokenCount
|
||||
state.outputTokens = payload.UsageMetadata.CandidatesTokenCount + state.reasoningTokens
|
||||
state.cachedTokens = payload.UsageMetadata.CachedContentTokenCount
|
||||
state.hasUsage = true
|
||||
}
|
||||
|
||||
@@ -288,7 +288,7 @@ func TestStreamCompletionEmitsTextUsageAndReasoningTokens(t *testing.T) {
|
||||
want := []zeroruntime.StreamEvent{
|
||||
{Type: zeroruntime.StreamEventText, Content: "Hello"},
|
||||
{Type: zeroruntime.StreamEventText, Content: " Zero"},
|
||||
{Type: zeroruntime.StreamEventUsage, Usage: zeroruntime.Usage{InputTokens: 25, OutputTokens: 18, PromptTokens: 25, CompletionTokens: 18, ReasoningTokens: 3, CachedInputTokens: 7}},
|
||||
{Type: zeroruntime.StreamEventUsage, Usage: zeroruntime.Usage{InputTokens: 25, OutputTokens: 15, PromptTokens: 25, CompletionTokens: 15, ReasoningTokens: 3, CachedInputTokens: 7}},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
}
|
||||
if !reflect.DeepEqual(events, want) {
|
||||
|
||||
@@ -71,21 +71,11 @@ const (
|
||||
|
||||
// responsesRequest is the wire shape POSTed to {baseURL}/responses.
|
||||
type responsesRequest struct {
|
||||
Model string `json:"model"`
|
||||
Instructions string `json:"instructions"`
|
||||
Input []inputItem `json:"input"`
|
||||
Stream bool `json:"stream"`
|
||||
Store bool `json:"store"`
|
||||
MaxOutputTokens int `json:"max_output_tokens,omitempty"`
|
||||
Tools []responsesTool `json:"tools,omitempty"`
|
||||
Reasoning *responsesReasoning `json:"reasoning,omitempty"`
|
||||
}
|
||||
|
||||
// responsesReasoning carries the reasoning controls for the Responses API. The
|
||||
// chat-completions `reasoning_effort` field is nested under `reasoning.effort`
|
||||
// here; omitted entirely when the caller requests no (or an unsupported) effort.
|
||||
type responsesReasoning struct {
|
||||
Effort string `json:"effort,omitempty"`
|
||||
Model string `json:"model"`
|
||||
Input []inputItem `json:"input"`
|
||||
Stream bool `json:"stream"`
|
||||
MaxOutputTokens int `json:"max_output_tokens,omitempty"`
|
||||
Tools []responsesTool `json:"tools,omitempty"`
|
||||
}
|
||||
|
||||
// inputItem is one element of the Responses `input` array. The Type field
|
||||
@@ -213,13 +203,18 @@ func (p *CodexProvider) buildResponsesRequest(request zeroruntime.CompletionRequ
|
||||
Stream: true,
|
||||
MaxOutputTokens: p.inner.maxTokens,
|
||||
}
|
||||
instructions := []string{}
|
||||
for _, msg := range request.Messages {
|
||||
switch msg.Role {
|
||||
case zeroruntime.MessageRoleSystem:
|
||||
if content := strings.TrimSpace(msg.Content); content != "" {
|
||||
instructions = append(instructions, content)
|
||||
}
|
||||
// System → first-class user message in Responses API. Keeping it as
|
||||
// an input message (rather than a separate `instructions` field)
|
||||
// matches the chat-completions semantics the runtime was already
|
||||
// producing — every system turn reaches the model in order.
|
||||
req.Input = append(req.Input, inputItem{
|
||||
Type: "message",
|
||||
Role: "system",
|
||||
Content: []contentItem{{Type: "input_text", Text: msg.Content}},
|
||||
})
|
||||
case zeroruntime.MessageRoleUser:
|
||||
req.Input = append(req.Input, p.userInputItem(msg))
|
||||
case zeroruntime.MessageRoleAssistant:
|
||||
@@ -241,7 +236,6 @@ func (p *CodexProvider) buildResponsesRequest(request zeroruntime.CompletionRequ
|
||||
return nil, fmt.Errorf("codex provider: unsupported message role %q", msg.Role)
|
||||
}
|
||||
}
|
||||
req.Instructions = strings.Join(instructions, "\n\n")
|
||||
for _, tool := range request.Tools {
|
||||
req.Tools = append(req.Tools, responsesTool{
|
||||
Type: "function",
|
||||
@@ -250,14 +244,6 @@ func (p *CodexProvider) buildResponsesRequest(request zeroruntime.CompletionRequ
|
||||
Parameters: tool.Parameters,
|
||||
})
|
||||
}
|
||||
// Codex / o-series reasoning models take the effort tier nested under
|
||||
// `reasoning` on the Responses API (the chat-completions `reasoning_effort`
|
||||
// moved here). Reuse the chat normalizer so only API-accepted values are sent
|
||||
// and an empty or unsupported effort simply omits the field — without this the
|
||||
// caller's chosen effort was silently dropped for every Codex model.
|
||||
if effort := openAIReasoningEffort(request.ReasoningEffort); effort != "" {
|
||||
req.Reasoning = &responsesReasoning{Effort: effort}
|
||||
}
|
||||
return req, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -385,10 +385,9 @@ func TestCodexProviderOmitsAccountIDWhenResolverSaysNo(t *testing.T) {
|
||||
|
||||
func TestCodexProviderSendsResponsesRequestShape(t *testing.T) {
|
||||
// The Codex provider speaks the Responses API, not the chat-completions
|
||||
// API. The request body must carry top-level `instructions`, `input` items
|
||||
// (not `messages`), a `stream: true` flag, and `tools` (when the caller
|
||||
// supplies any) — a chat-completions body would be rejected by the live
|
||||
// Codex backend.
|
||||
// API. The request body must carry `input` items (not `messages`), a
|
||||
// `stream: true` flag, and `tools` (when the caller supplies any) — a
|
||||
// chat-completions body would be rejected by the live Codex backend.
|
||||
var rec codexRequest
|
||||
srv := newCodexTestServer(t, &rec)
|
||||
defer srv.Close()
|
||||
@@ -424,25 +423,23 @@ func TestCodexProviderSendsResponsesRequestShape(t *testing.T) {
|
||||
if rec.body["stream"] != true {
|
||||
t.Fatalf("body.stream = %#v, want true", rec.body["stream"])
|
||||
}
|
||||
if store, ok := rec.body["store"].(bool); !ok || store {
|
||||
t.Fatalf("body.store = %#v, want explicit false", rec.body["store"])
|
||||
}
|
||||
if _, ok := rec.body["messages"]; ok {
|
||||
t.Fatalf("body must not carry chat-completions `messages` (got %#v); the Codex backend serves the Responses API", rec.body["messages"])
|
||||
}
|
||||
if rec.body["instructions"] != "sys" {
|
||||
t.Fatalf("body.instructions = %#v, want system prompt", rec.body["instructions"])
|
||||
}
|
||||
input, ok := rec.body["input"].([]any)
|
||||
if !ok {
|
||||
t.Fatalf("body.input = %#v, want []any with user message item", rec.body["input"])
|
||||
t.Fatalf("body.input = %#v, want []any with two message items", rec.body["input"])
|
||||
}
|
||||
if len(input) != 1 {
|
||||
t.Fatalf("body.input has %d items, want 1 (user)", len(input))
|
||||
if len(input) != 2 {
|
||||
t.Fatalf("body.input has %d items, want 2 (system + user)", len(input))
|
||||
}
|
||||
user, _ := input[0].(map[string]any)
|
||||
system, _ := input[0].(map[string]any)
|
||||
if system["type"] != "message" || system["role"] != "system" {
|
||||
t.Fatalf("body.input[0] = %#v, want type=message role=system", system)
|
||||
}
|
||||
user, _ := input[1].(map[string]any)
|
||||
if user["type"] != "message" || user["role"] != "user" {
|
||||
t.Fatalf("body.input[0] = %#v, want type=message role=user", user)
|
||||
t.Fatalf("body.input[1] = %#v, want type=message role=user", user)
|
||||
}
|
||||
tools, ok := rec.body["tools"].([]any)
|
||||
if !ok || len(tools) != 1 {
|
||||
@@ -454,67 +451,6 @@ func TestCodexProviderSendsResponsesRequestShape(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexProviderForwardsReasoningEffort(t *testing.T) {
|
||||
// A reasoning effort must reach the Responses backend nested under
|
||||
// `reasoning.effort` (where the chat-completions `reasoning_effort` moved).
|
||||
// Without this the user's chosen effort is silently dropped for Codex models.
|
||||
var rec codexRequest
|
||||
srv := newCodexTestServer(t, &rec)
|
||||
defer srv.Close()
|
||||
|
||||
provider, err := NewCodexProvider(CodexOptions{
|
||||
Options: Options{APIKey: "sk-test", BaseURL: srv.URL, Model: "gpt-5-codex"},
|
||||
AccountID: "acc-x",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewCodexProvider: %v", err)
|
||||
}
|
||||
stream, err := provider.StreamCompletion(context.Background(), zeroruntime.CompletionRequest{
|
||||
Messages: []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "hi"}},
|
||||
ReasoningEffort: "high",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("StreamCompletion: %v", err)
|
||||
}
|
||||
drainCodexEvents(t, stream)
|
||||
|
||||
reasoning, ok := rec.body["reasoning"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("body.reasoning = %#v, want an object carrying the effort", rec.body["reasoning"])
|
||||
}
|
||||
if reasoning["effort"] != "high" {
|
||||
t.Fatalf("body.reasoning.effort = %#v, want high", reasoning["effort"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexProviderOmitsReasoningWhenUnset(t *testing.T) {
|
||||
// No effort (and unsupported values) must omit the `reasoning` field entirely
|
||||
// rather than send an empty object, which the backend would reject.
|
||||
var rec codexRequest
|
||||
srv := newCodexTestServer(t, &rec)
|
||||
defer srv.Close()
|
||||
|
||||
provider, err := NewCodexProvider(CodexOptions{
|
||||
Options: Options{APIKey: "sk-test", BaseURL: srv.URL, Model: "gpt-5-codex"},
|
||||
AccountID: "acc-x",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewCodexProvider: %v", err)
|
||||
}
|
||||
stream, err := provider.StreamCompletion(context.Background(), zeroruntime.CompletionRequest{
|
||||
Messages: []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "hi"}},
|
||||
ReasoningEffort: "", // auto
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("StreamCompletion: %v", err)
|
||||
}
|
||||
drainCodexEvents(t, stream)
|
||||
|
||||
if _, ok := rec.body["reasoning"]; ok {
|
||||
t.Fatalf("body.reasoning must be omitted when no effort is requested, got %#v", rec.body["reasoning"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexProviderRetriesHeadersAfter401(t *testing.T) {
|
||||
var hits atomic.Int32
|
||||
var rec1, rec2 codexRequest
|
||||
|
||||
@@ -310,7 +310,6 @@ func (provider *Provider) emitChunk(
|
||||
PromptTokens: chunk.Usage.PromptTokens,
|
||||
CompletionTokens: chunk.Usage.CompletionTokens,
|
||||
CachedInputTokens: chunk.Usage.PromptTokensDetails.CachedTokens,
|
||||
ReasoningTokens: chunk.Usage.CompletionTokensDetails.ReasoningTokens,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -385,14 +384,6 @@ func sendBufferedEvent(events chan<- zeroruntime.StreamEvent, event zeroruntime.
|
||||
func (provider *Provider) openAIRequest(request zeroruntime.CompletionRequest) chatCompletionRequest {
|
||||
messages := make([]chatMessage, 0, len(request.Messages))
|
||||
for _, message := range request.Messages {
|
||||
// Drop a degenerate assistant turn that carries neither text nor tool calls
|
||||
// (e.g. a sub-agent that failed with no output). The Anthropic/Gemini mappers
|
||||
// already skip empty turns; without this, the contentless message reaches
|
||||
// strict OpenAI-compatible servers and is rejected.
|
||||
if message.Role == zeroruntime.MessageRoleAssistant &&
|
||||
strings.TrimSpace(message.Content) == "" && len(message.ToolCalls) == 0 {
|
||||
continue
|
||||
}
|
||||
messages = append(messages, mapMessage(message))
|
||||
}
|
||||
|
||||
@@ -453,12 +444,11 @@ func mapMessage(message zeroruntime.Message) chatMessage {
|
||||
// message that happens to carry Images keeps the plain string/nil content
|
||||
// path (its images are simply not serialized).
|
||||
if len(message.Images) == 0 || message.Role != zeroruntime.MessageRoleUser {
|
||||
// Always set content (to "" when empty) so it serializes as `"content":""`
|
||||
// rather than being dropped. Strict OpenAI-compatible servers reject a
|
||||
// message with no content field; tool results and assistant-with-tool-calls
|
||||
// turns must still carry an (empty) content. Truly empty assistant turns are
|
||||
// dropped upstream in openAIRequest.
|
||||
mapped.Content = message.Content
|
||||
// Preserve the legacy behavior: only non-empty text is serialized, so
|
||||
// empty content is omitted by the `omitempty` tag.
|
||||
if message.Content != "" {
|
||||
mapped.Content = message.Content
|
||||
}
|
||||
} else {
|
||||
parts := make([]contentPart, 0, len(message.Images)+1)
|
||||
if message.Content != "" {
|
||||
|
||||
@@ -1000,6 +1000,57 @@ func TestStreamCompletionEmitsDroppedOnNamelessToolCall(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIRequestTextOnlyOmitsEmptyContent(t *testing.T) {
|
||||
provider, err := New(Options{Model: "gpt-test"})
|
||||
if err != nil {
|
||||
t.Fatalf("New returned error: %v", err)
|
||||
}
|
||||
|
||||
got, err := json.Marshal(provider.openAIRequest(zeroruntime.CompletionRequest{
|
||||
Messages: []zeroruntime.Message{
|
||||
{Role: zeroruntime.MessageRoleSystem, Content: "system"},
|
||||
{Role: zeroruntime.MessageRoleUser, Content: "hello"},
|
||||
{
|
||||
Role: zeroruntime.MessageRoleAssistant,
|
||||
Content: "", // assistant turn that is *only* a tool call -> empty content must drop
|
||||
ToolCalls: []zeroruntime.ToolCall{{
|
||||
ID: "call_1",
|
||||
Name: "read_file",
|
||||
Arguments: `{"path":"README.md"}`,
|
||||
}},
|
||||
},
|
||||
{Role: zeroruntime.MessageRoleTool, Content: "contents", ToolCallID: "call_1"},
|
||||
},
|
||||
}))
|
||||
if err != nil {
|
||||
t.Fatalf("marshal request: %v", err)
|
||||
}
|
||||
|
||||
// String content round-trips as a JSON string, and empty content is omitted.
|
||||
if !strings.Contains(string(got), `"content":"hello"`) {
|
||||
t.Fatalf("user content not serialized as string: %s", got)
|
||||
}
|
||||
if strings.Contains(string(got), `"content":""`) {
|
||||
t.Fatalf("empty assistant content must be omitted, got: %s", got)
|
||||
}
|
||||
|
||||
// Decode the assistant message and assert no content key at all.
|
||||
var decoded chatCompletionRequest
|
||||
var raw struct {
|
||||
Messages []map[string]any `json:"messages"`
|
||||
}
|
||||
if err := json.Unmarshal(got, &raw); err != nil {
|
||||
t.Fatalf("unmarshal request: %v", err)
|
||||
}
|
||||
_ = decoded
|
||||
if _, present := raw.Messages[2]["content"]; present {
|
||||
t.Fatalf("assistant (tool-only) message must omit content key, got: %#v", raw.Messages[2])
|
||||
}
|
||||
if raw.Messages[1]["content"] != "hello" {
|
||||
t.Fatalf("user content = %#v, want \"hello\"", raw.Messages[1]["content"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestContentPartImageURLMarshalsDataURI(t *testing.T) {
|
||||
parts := []contentPart{
|
||||
{Type: "text", Text: "look"},
|
||||
@@ -1025,6 +1076,38 @@ func TestContentPartImageURLMarshalsDataURI(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIRequestEmptyContentStaysOmittedAfterAnyRefactor(t *testing.T) {
|
||||
provider, err := New(Options{Model: "gpt-test"})
|
||||
if err != nil {
|
||||
t.Fatalf("New returned error: %v", err)
|
||||
}
|
||||
got, err := json.Marshal(provider.openAIRequest(zeroruntime.CompletionRequest{
|
||||
Messages: []zeroruntime.Message{
|
||||
{
|
||||
Role: zeroruntime.MessageRoleAssistant,
|
||||
Content: "",
|
||||
ToolCalls: []zeroruntime.ToolCall{{
|
||||
ID: "call_1",
|
||||
Name: "read_file",
|
||||
Arguments: `{}`,
|
||||
}},
|
||||
},
|
||||
},
|
||||
}))
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
var raw struct {
|
||||
Messages []map[string]any `json:"messages"`
|
||||
}
|
||||
if err := json.Unmarshal(got, &raw); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if _, present := raw.Messages[0]["content"]; present {
|
||||
t.Fatalf("empty content boxed in any must stay omitted, got: %#v", raw.Messages[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapMessageBuildsImageURLContentParts(t *testing.T) {
|
||||
msg := mapMessage(zeroruntime.Message{
|
||||
Role: zeroruntime.MessageRoleUser,
|
||||
@@ -1082,8 +1165,8 @@ func TestMapMessageTextOnlyKeepsStringContent(t *testing.T) {
|
||||
t.Fatalf("Content = %#v, want string \"hi\"", msg.Content)
|
||||
}
|
||||
empty := mapMessage(zeroruntime.Message{Role: zeroruntime.MessageRoleAssistant, Content: ""})
|
||||
if got, ok := empty.Content.(string); !ok || got != "" {
|
||||
t.Fatalf("empty text content = %#v, want \"\" so it serializes as content:\"\" (strict servers reject a missing/null content)", empty.Content)
|
||||
if empty.Content != nil {
|
||||
t.Fatalf("empty text content = %#v, want nil so omitempty drops it", empty.Content)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1163,66 +1246,3 @@ func TestStreamCompletionSerializesImageContentParts(t *testing.T) {
|
||||
t.Fatalf("image url = %#v, want data:image/png;base64,QUJD", imageURL["url"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestOpenAIRequestEmptyContentHandling locks in the fix for strict OpenAI-
|
||||
// compatible servers (e.g. glm-* on Ollama-cloud) that reject a message whose
|
||||
// `content` is absent/null with "invalid message content type: <nil>": a
|
||||
// contentless assistant turn with no tool calls is dropped, and every other
|
||||
// message serializes an explicit `"content"` field (empty string when there is
|
||||
// no text) instead of omitting it.
|
||||
func TestOpenAIRequestEmptyContentHandling(t *testing.T) {
|
||||
provider, err := New(Options{Model: "gpt-test"})
|
||||
if err != nil {
|
||||
t.Fatalf("New returned error: %v", err)
|
||||
}
|
||||
|
||||
req := provider.openAIRequest(zeroruntime.CompletionRequest{
|
||||
Messages: []zeroruntime.Message{
|
||||
{Role: zeroruntime.MessageRoleUser, Content: "hi"},
|
||||
// Degenerate empty assistant turn (e.g. a sub-agent that failed with no
|
||||
// output): must be dropped, not sent.
|
||||
{Role: zeroruntime.MessageRoleAssistant, Content: " "},
|
||||
// Assistant with tool calls but no text: kept, content present as "".
|
||||
{Role: zeroruntime.MessageRoleAssistant, Content: "", ToolCalls: []zeroruntime.ToolCall{{
|
||||
ID: "call_1", Name: "read_file", Arguments: "{}",
|
||||
}}},
|
||||
// Tool result with empty content: kept, content present as "".
|
||||
{Role: zeroruntime.MessageRoleTool, Content: "", ToolCallID: "call_1"},
|
||||
},
|
||||
})
|
||||
|
||||
if len(req.Messages) != 3 {
|
||||
t.Fatalf("empty assistant turn should be dropped; got %d messages: %#v", len(req.Messages), req.Messages)
|
||||
}
|
||||
|
||||
// No message may serialize with an absent/null content field.
|
||||
for i, message := range req.Messages {
|
||||
if message.Content == nil {
|
||||
t.Fatalf("message %d has nil content (would serialize as null/omitted): %#v", i, message)
|
||||
}
|
||||
data, err := json.Marshal(message)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal message %d: %v", i, err)
|
||||
}
|
||||
if !strings.Contains(string(data), `"content":`) {
|
||||
t.Fatalf("message %d omits the content field: %s", i, data)
|
||||
}
|
||||
}
|
||||
|
||||
if req.Messages[0].Content != "hi" {
|
||||
t.Fatalf("user content = %#v, want \"hi\"", req.Messages[0].Content)
|
||||
}
|
||||
// The kept-but-textless messages must send content as an explicit empty string.
|
||||
for _, idx := range []int{1, 2} {
|
||||
data, err := json.Marshal(req.Messages[idx])
|
||||
if err != nil {
|
||||
t.Fatalf("marshal message %d: %v", idx, err)
|
||||
}
|
||||
if !strings.Contains(string(data), `"content":""`) {
|
||||
t.Fatalf("message %d should send content:\"\", got %s", idx, data)
|
||||
}
|
||||
}
|
||||
if len(req.Messages[1].ToolCalls) != 1 {
|
||||
t.Fatalf("assistant tool calls dropped: %#v", req.Messages[1])
|
||||
}
|
||||
}
|
||||
@@ -18,13 +18,8 @@ type streamOptions struct {
|
||||
}
|
||||
|
||||
type chatMessage struct {
|
||||
Role string `json:"role"`
|
||||
// Content has no omitempty: strict OpenAI-compatible servers (e.g. some
|
||||
// Ollama-cloud models like glm-*) reject a message whose `content` is absent
|
||||
// or null with "invalid message content type: <nil>". mapMessage always sets
|
||||
// this (to "" when there's no text), so a contentless message serializes as
|
||||
// `"content":""` rather than being dropped.
|
||||
Content any `json:"content"`
|
||||
Role string `json:"role"`
|
||||
Content any `json:"content,omitempty"`
|
||||
ToolCalls []requestToolCall `json:"tool_calls,omitempty"`
|
||||
ToolCallID string `json:"tool_call_id,omitempty"`
|
||||
}
|
||||
@@ -93,20 +88,15 @@ type streamFunctionDelta struct {
|
||||
}
|
||||
|
||||
type usage struct {
|
||||
PromptTokens int `json:"prompt_tokens"`
|
||||
CompletionTokens int `json:"completion_tokens"`
|
||||
PromptTokensDetails promptTokenDetails `json:"prompt_tokens_details"`
|
||||
CompletionTokensDetails completionTokenDetails `json:"completion_tokens_details"`
|
||||
PromptTokens int `json:"prompt_tokens"`
|
||||
CompletionTokens int `json:"completion_tokens"`
|
||||
PromptTokensDetails promptTokenDetails `json:"prompt_tokens_details"`
|
||||
}
|
||||
|
||||
type promptTokenDetails struct {
|
||||
CachedTokens int `json:"cached_tokens"`
|
||||
}
|
||||
|
||||
type completionTokenDetails struct {
|
||||
ReasoningTokens int `json:"reasoning_tokens"`
|
||||
}
|
||||
|
||||
type apiError struct {
|
||||
Message string `json:"message"`
|
||||
Type string `json:"type"`
|
||||
|
||||
+2
-111
@@ -243,9 +243,6 @@ func Package(ctx context.Context, options PackageOptions) (PackageResult, error)
|
||||
if err := copyPackageFiles(rootDir, stagingDir, artifactPath, stagedBinaryPath, goos, version, helperArtifacts); err != nil {
|
||||
return PackageResult{}, err
|
||||
}
|
||||
if err := stageLocalControlHelpers(ctx, rootDir, filepath.Join(stagingDir, "helpers")); err != nil {
|
||||
return PackageResult{}, err
|
||||
}
|
||||
if err := createArchive(stagingDir, archivePath, goos); err != nil {
|
||||
return PackageResult{}, err
|
||||
}
|
||||
@@ -710,105 +707,6 @@ func copyPackageFiles(rootDir string, stagingDir string, artifactPath string, st
|
||||
return nil
|
||||
}
|
||||
|
||||
var localControlHelperPackages = []string{"agent-browser", "tuistory"}
|
||||
|
||||
func stageLocalControlHelpers(ctx context.Context, rootDir string, helpersDir string) error {
|
||||
dependencies, err := localControlHelperDependencies(rootDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(helpersDir, 0o755); err != nil {
|
||||
return fmt.Errorf("create local-control helper staging dir: %w", err)
|
||||
}
|
||||
manifest := map[string]any{
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": dependencies,
|
||||
}
|
||||
bytes, err := json.MarshalIndent(manifest, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(helpersDir, "package.json"), append(bytes, '\n'), 0o644); err != nil {
|
||||
return fmt.Errorf("write local-control helper package.json: %w", err)
|
||||
}
|
||||
lockfilePath := filepath.Join(rootDir, "package-lock.json")
|
||||
if _, err := os.Stat(lockfilePath); err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return fmt.Errorf("package-lock.json is required to package local-control helpers")
|
||||
}
|
||||
return fmt.Errorf("stat package-lock.json: %w", err)
|
||||
}
|
||||
if err := copyFile(lockfilePath, filepath.Join(helpersDir, "package-lock.json"), 0o644); err != nil {
|
||||
return fmt.Errorf("copy local-control helper package-lock.json: %w", err)
|
||||
}
|
||||
|
||||
npmPath, err := exec.LookPath("npm")
|
||||
if err != nil {
|
||||
return fmt.Errorf("npm is required to package local-control helpers: %w", err)
|
||||
}
|
||||
command := exec.CommandContext(ctx, npmPath, "ci", "--omit=dev", "--no-audit", "--no-fund", "--loglevel=error")
|
||||
command.Dir = helpersDir
|
||||
command.Env = append(os.Environ(), "npm_config_update_notifier=false")
|
||||
output, err := command.CombinedOutput()
|
||||
if err != nil {
|
||||
message := strings.TrimSpace(string(output))
|
||||
if message == "" {
|
||||
message = err.Error()
|
||||
}
|
||||
return fmt.Errorf("install local-control helper packages: %s", message)
|
||||
}
|
||||
if err := verifyStagedLocalControlHelpers(helpersDir, runtime.GOOS); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func localControlHelperDependencies(rootDir string) (map[string]string, error) {
|
||||
bytes, err := os.ReadFile(filepath.Join(rootDir, "package.json"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var payload struct {
|
||||
Dependencies map[string]string `json:"dependencies"`
|
||||
}
|
||||
if err := json.Unmarshal(bytes, &payload); err != nil {
|
||||
return nil, fmt.Errorf("parse package.json: %w", err)
|
||||
}
|
||||
dependencies := map[string]string{}
|
||||
for _, name := range localControlHelperPackages {
|
||||
version := strings.TrimSpace(payload.Dependencies[name])
|
||||
if version == "" {
|
||||
return nil, fmt.Errorf("package.json dependency %q is required for packaged local-control helpers", name)
|
||||
}
|
||||
dependencies[name] = version
|
||||
}
|
||||
return dependencies, nil
|
||||
}
|
||||
|
||||
func verifyStagedLocalControlHelpers(helpersDir string, goos string) error {
|
||||
for _, name := range localControlHelperPackages {
|
||||
found := false
|
||||
for _, shimName := range localControlHelperShimNames(name, goos) {
|
||||
if info, err := os.Stat(filepath.Join(helpersDir, "node_modules", ".bin", shimName)); err == nil && !info.IsDir() {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return fmt.Errorf("local-control helper package did not create a %s executable shim", name)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func localControlHelperShimNames(name string, goos string) []string {
|
||||
if goos == "windows" {
|
||||
return []string{name + ".cmd", name + ".exe", name}
|
||||
}
|
||||
return []string{name}
|
||||
}
|
||||
|
||||
func copyFile(source string, destination string, mode fs.FileMode) error {
|
||||
if err := os.MkdirAll(filepath.Dir(destination), 0o755); err != nil {
|
||||
return err
|
||||
@@ -865,14 +763,7 @@ func createTarGzArchive(stagingDir string, archivePath string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
linkTarget := ""
|
||||
if info.Mode()&os.ModeSymlink != 0 {
|
||||
linkTarget, err = os.Readlink(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
header, err := tar.FileInfoHeader(info, linkTarget)
|
||||
header, err := tar.FileInfoHeader(info, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -880,7 +771,7 @@ func createTarGzArchive(stagingDir string, archivePath string) error {
|
||||
if err := tarWriter.WriteHeader(header); err != nil {
|
||||
return err
|
||||
}
|
||||
if entry.IsDir() || info.Mode()&os.ModeSymlink != 0 {
|
||||
if entry.IsDir() {
|
||||
return nil
|
||||
}
|
||||
entryFile, err := os.Open(path)
|
||||
|
||||
@@ -357,32 +357,6 @@ func TestCreateArchivesWithRootPackageFiles(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestCreateTarArchivePreservesSymlinkTargets(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("Unix symlink archive behavior")
|
||||
}
|
||||
stagingDir := packageStagingFixture(t, "zero")
|
||||
linkPath := filepath.Join(stagingDir, "helpers", "node_modules", ".bin", "agent-browser")
|
||||
if err := os.MkdirAll(filepath.Dir(linkPath), 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
linkTarget := "../agent-browser/bin/agent-browser.js"
|
||||
if err := os.Symlink(linkTarget, linkPath); err != nil {
|
||||
t.Fatalf("Symlink: %v", err)
|
||||
}
|
||||
archivePath := filepath.Join(t.TempDir(), "zero-v0.1.0-linux-x64.tar.gz")
|
||||
if err := createArchive(stagingDir, archivePath, "linux"); err != nil {
|
||||
t.Fatalf("createArchive returned error: %v", err)
|
||||
}
|
||||
header := tarArchiveHeaders(t, archivePath)["helpers/node_modules/.bin/agent-browser"]
|
||||
if header == nil {
|
||||
t.Fatal("archive missing symlink header")
|
||||
}
|
||||
if header.Typeflag != tar.TypeSymlink || header.Linkname != linkTarget {
|
||||
t.Fatalf("symlink header type/link = %v/%q, want %v/%q", header.Typeflag, header.Linkname, tar.TypeSymlink, linkTarget)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCopyPackageFilesStagesLinuxSandboxHelper(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
staging := t.TempDir()
|
||||
@@ -443,95 +417,6 @@ func TestCopyPackageFilesStagesWindowsSandboxHelpers(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestStageLocalControlHelpersUsesPackageDependencies(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
helpers := filepath.Join(t.TempDir(), "helpers")
|
||||
mustWriteFile(t, filepath.Join(root, "package.json"), `{
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"agent-browser": "0.30.1",
|
||||
"tuistory": "0.10.0"
|
||||
}
|
||||
}`)
|
||||
mustWriteFile(t, filepath.Join(root, "package-lock.json"), `{
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"dependencies": {
|
||||
"agent-browser": "0.30.1",
|
||||
"tuistory": "0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/agent-browser": {
|
||||
"version": "0.30.1"
|
||||
},
|
||||
"node_modules/tuistory": {
|
||||
"version": "0.10.0"
|
||||
}
|
||||
}
|
||||
}`)
|
||||
fakeBin := t.TempDir()
|
||||
writeFakeNPM(t, fakeBin)
|
||||
t.Setenv("PATH", fakeBin+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||
|
||||
if err := stageLocalControlHelpers(context.Background(), root, helpers); err != nil {
|
||||
t.Fatalf("stageLocalControlHelpers: %v", err)
|
||||
}
|
||||
manifestBytes, err := os.ReadFile(filepath.Join(helpers, "package.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile helper package.json: %v", err)
|
||||
}
|
||||
manifest := string(manifestBytes)
|
||||
for _, want := range []string{`"agent-browser": "0.30.1"`, `"tuistory": "0.10.0"`} {
|
||||
if !strings.Contains(manifest, want) {
|
||||
t.Fatalf("helper package.json missing %s:\n%s", want, manifest)
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(helpers, "package-lock.json")); err != nil {
|
||||
t.Fatalf("helper package-lock.json was not copied: %v", err)
|
||||
}
|
||||
for _, name := range localControlHelperPackages {
|
||||
found := false
|
||||
for _, shimName := range localControlHelperShimNames(name, runtime.GOOS) {
|
||||
if _, err := os.Stat(filepath.Join(helpers, "node_modules", ".bin", shimName)); err == nil {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("missing shim for %s", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStageLocalControlHelpersRequiresPackageLock(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
helpers := filepath.Join(t.TempDir(), "helpers")
|
||||
mustWriteFile(t, filepath.Join(root, "package.json"), `{
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"agent-browser": "0.30.1",
|
||||
"tuistory": "0.10.0"
|
||||
}
|
||||
}`)
|
||||
|
||||
err := stageLocalControlHelpers(context.Background(), root, helpers)
|
||||
if err == nil || !strings.Contains(err.Error(), "package-lock.json is required") {
|
||||
t.Fatalf("stageLocalControlHelpers error = %v, want package-lock requirement", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalControlHelperDependenciesRequiresConfiguredPackages(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
mustWriteFile(t, filepath.Join(root, "package.json"), `{"version":"0.1.0","dependencies":{"agent-browser":"0.30.1"}}`)
|
||||
|
||||
_, err := localControlHelperDependencies(root)
|
||||
if err == nil || !strings.Contains(err.Error(), `dependency "tuistory"`) {
|
||||
t.Fatalf("localControlHelperDependencies error = %v, want missing tuistory", err)
|
||||
}
|
||||
}
|
||||
|
||||
func packageStagingFixture(t *testing.T, binaryName string) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
@@ -553,43 +438,6 @@ func packageStagingFixture(t *testing.T, binaryName string) string {
|
||||
return dir
|
||||
}
|
||||
|
||||
func writeFakeNPM(t *testing.T, dir string) {
|
||||
t.Helper()
|
||||
if runtime.GOOS == "windows" {
|
||||
path := filepath.Join(dir, "npm.cmd")
|
||||
content := `@echo off
|
||||
if not "%1"=="ci" (
|
||||
echo expected npm ci 1>&2
|
||||
exit /b 2
|
||||
)
|
||||
mkdir node_modules 2>NUL
|
||||
mkdir node_modules\.bin 2>NUL
|
||||
echo @echo off> node_modules\.bin\agent-browser.cmd
|
||||
echo @echo off> node_modules\.bin\tuistory.cmd
|
||||
exit /b 0
|
||||
`
|
||||
mustWriteFile(t, path, content)
|
||||
return
|
||||
}
|
||||
path := filepath.Join(dir, "npm")
|
||||
content := `#!/usr/bin/env sh
|
||||
set -eu
|
||||
if [ "${1:-}" != "ci" ]; then
|
||||
echo "expected npm ci" >&2
|
||||
exit 2
|
||||
fi
|
||||
mkdir -p node_modules/.bin
|
||||
for name in agent-browser tuistory; do
|
||||
printf '#!/usr/bin/env sh\n' > "node_modules/.bin/$name"
|
||||
chmod 755 "node_modules/.bin/$name"
|
||||
done
|
||||
`
|
||||
mustWriteFile(t, path, content)
|
||||
if err := os.Chmod(path, 0o755); err != nil {
|
||||
t.Fatalf("Chmod fake npm: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func mustWriteFile(t *testing.T, path string, content string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
@@ -601,16 +449,6 @@ func mustWriteFile(t *testing.T, path string, content string) {
|
||||
}
|
||||
|
||||
func tarArchiveNames(t *testing.T, archivePath string) map[string]bool {
|
||||
t.Helper()
|
||||
headers := tarArchiveHeaders(t, archivePath)
|
||||
names := map[string]bool{}
|
||||
for name := range headers {
|
||||
names[name] = true
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
func tarArchiveHeaders(t *testing.T, archivePath string) map[string]*tar.Header {
|
||||
t.Helper()
|
||||
file, err := os.Open(archivePath)
|
||||
if err != nil {
|
||||
@@ -627,7 +465,7 @@ func tarArchiveHeaders(t *testing.T, archivePath string) map[string]*tar.Header
|
||||
_ = gzipReader.Close()
|
||||
}()
|
||||
reader := tar.NewReader(gzipReader)
|
||||
headers := map[string]*tar.Header{}
|
||||
names := map[string]bool{}
|
||||
for {
|
||||
header, err := reader.Next()
|
||||
if err != nil {
|
||||
@@ -636,10 +474,9 @@ func tarArchiveHeaders(t *testing.T, archivePath string) map[string]*tar.Header
|
||||
}
|
||||
t.Fatalf("tar Next: %v", err)
|
||||
}
|
||||
copied := *header
|
||||
headers[header.Name] = &copied
|
||||
names[header.Name] = true
|
||||
}
|
||||
return headers
|
||||
return names
|
||||
}
|
||||
|
||||
func zipArchiveNames(t *testing.T, archivePath string) map[string]bool {
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
package reltime
|
||||
|
||||
import "fmt"
|
||||
|
||||
// RelTime renders a duration in seconds as a human-readable relative-time
|
||||
// string. This is the ORIGINAL implementation, kept verbatim so
|
||||
// characterization tests can be pinned against it before refactoring.
|
||||
func RelTime(d int) string {
|
||||
if d < 0 {
|
||||
d = -d
|
||||
if d < 60 {
|
||||
if d == 1 {
|
||||
return "1 second ago"
|
||||
}
|
||||
return fmt.Sprintf("%d seconds ago", d)
|
||||
}
|
||||
if d < 3600 {
|
||||
m := d / 60
|
||||
if m == 1 {
|
||||
return "1 minute ago"
|
||||
}
|
||||
return fmt.Sprintf("%d minutes ago", m)
|
||||
}
|
||||
if d < 86400 {
|
||||
h := d / 3600
|
||||
if h == 1 {
|
||||
return "1 hour ago"
|
||||
}
|
||||
return fmt.Sprintf("%d hours ago", h)
|
||||
}
|
||||
x := d / 86400
|
||||
if x == 1 {
|
||||
return "1 day ago"
|
||||
}
|
||||
return fmt.Sprintf("%d days ago", x)
|
||||
} else if d == 0 {
|
||||
return "just now"
|
||||
} else {
|
||||
if d < 60 {
|
||||
if d == 1 {
|
||||
return "in 1 second"
|
||||
}
|
||||
return fmt.Sprintf("in %d seconds", d)
|
||||
}
|
||||
if d < 3600 {
|
||||
m := d / 60
|
||||
if m == 1 {
|
||||
return "in 1 minute"
|
||||
}
|
||||
return fmt.Sprintf("in %d minutes", m)
|
||||
}
|
||||
if d < 86400 {
|
||||
h := d / 3600
|
||||
if h == 1 {
|
||||
return "in 1 hour"
|
||||
}
|
||||
return fmt.Sprintf("in %d hours", h)
|
||||
}
|
||||
x := d / 86400
|
||||
if x == 1 {
|
||||
return "in 1 day"
|
||||
}
|
||||
return fmt.Sprintf("in %d days", x)
|
||||
}
|
||||
}
|
||||
@@ -34,15 +34,6 @@ var networkPrograms = map[string]bool{
|
||||
"ftp": true,
|
||||
}
|
||||
|
||||
var localServerPrograms = map[string]bool{
|
||||
"http-server": true,
|
||||
"serve": true,
|
||||
"vite": true,
|
||||
"next": true,
|
||||
"nuxt": true,
|
||||
"astro": true,
|
||||
}
|
||||
|
||||
// AnalyzeCommand parses script and reports interactive/destructive/network usage
|
||||
// from the shell AST. A script that cannot be parsed yields TooComplex (with no
|
||||
// other flags set) so the caller can decide how to treat an unanalyzable command.
|
||||
@@ -114,37 +105,25 @@ func commandUsesNetwork(prog string, args []*syntax.Word) bool {
|
||||
return true
|
||||
}
|
||||
words := literalWordTexts(args)
|
||||
if localServerPrograms[prog] {
|
||||
return true
|
||||
}
|
||||
switch prog {
|
||||
case "python", "python2", "python3", "py":
|
||||
return pythonModuleUsesNetwork(words)
|
||||
case "npm":
|
||||
return packageManagerUsesNetwork(words, map[string]string{
|
||||
"run": "run",
|
||||
"exec": "exec",
|
||||
"x": "exec",
|
||||
})
|
||||
return firstSubcommand(words, nil) == "install" ||
|
||||
firstSubcommand(words, nil) == "add" ||
|
||||
firstSubcommand(words, nil) == "publish" ||
|
||||
firstSubcommand(words, nil) == "login"
|
||||
case "pnpm":
|
||||
return packageManagerUsesNetwork(words, map[string]string{
|
||||
"run": "run",
|
||||
"exec": "exec",
|
||||
"dlx": "exec",
|
||||
})
|
||||
return firstSubcommand(words, nil) == "install" ||
|
||||
firstSubcommand(words, nil) == "add" ||
|
||||
firstSubcommand(words, nil) == "publish"
|
||||
case "yarn":
|
||||
return packageManagerUsesNetwork(words, map[string]string{
|
||||
"run": "run",
|
||||
"exec": "exec",
|
||||
"dlx": "exec",
|
||||
})
|
||||
return firstSubcommand(words, nil) == "add" ||
|
||||
firstSubcommand(words, nil) == "publish"
|
||||
case "bun":
|
||||
return packageManagerUsesNetwork(words, map[string]string{
|
||||
"run": "run",
|
||||
"x": "exec",
|
||||
})
|
||||
case "npx":
|
||||
return npxUsesNetwork(words)
|
||||
return firstSubcommand(words, nil) == "add" ||
|
||||
firstSubcommand(words, nil) == "install" ||
|
||||
firstSubcommand(words, nil) == "publish"
|
||||
case "pip", "pip2", "pip3":
|
||||
return firstSubcommand(words, nil) == "install"
|
||||
case "go":
|
||||
@@ -158,34 +137,6 @@ func commandUsesNetwork(prog string, args []*syntax.Word) bool {
|
||||
}
|
||||
}
|
||||
|
||||
func packageManagerUsesNetwork(words []string, aliases map[string]string) bool {
|
||||
first := firstSubcommand(words, aliases)
|
||||
switch first {
|
||||
case "install", "add", "publish", "login":
|
||||
return true
|
||||
case "start", "serve", "dev", "preview":
|
||||
return true
|
||||
case "run":
|
||||
second := secondSubcommand(words)
|
||||
return second == "start" || second == "serve" || second == "dev" || second == "preview"
|
||||
case "exec":
|
||||
for _, word := range words {
|
||||
if word == "" || strings.HasPrefix(word, "-") || isNumericToken(word) {
|
||||
continue
|
||||
}
|
||||
if word == "exec" || word == "x" || word == "dlx" {
|
||||
continue
|
||||
}
|
||||
return localServerPrograms[word]
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func npxUsesNetwork(_ []string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func literalWordTexts(args []*syntax.Word) []string {
|
||||
words := make([]string, 0, len(args))
|
||||
for _, arg := range args {
|
||||
|
||||
@@ -49,17 +49,10 @@ func TestAnalyzeCommand(t *testing.T) {
|
||||
{name: "python http server", script: "python3 -m http.server 8000", network: true},
|
||||
{name: "python pip install", script: "python3 -m pip install requests", network: true},
|
||||
{name: "npm install", script: "npm install", network: true},
|
||||
{name: "npm start", script: "npm start", network: true},
|
||||
{name: "npm run dev", script: "npm run dev", network: true},
|
||||
{name: "npx http server", script: "npx http-server public -p 8080 -a 127.0.0.1", network: true},
|
||||
{name: "direct http server", script: "http-server public -p 8080 -a 127.0.0.1", network: true},
|
||||
{name: "direct vite", script: "vite --host 127.0.0.1", network: true},
|
||||
{name: "next dev", script: "next dev", network: true},
|
||||
{name: "git clone", script: "git clone https://example.com/repo.git", network: true},
|
||||
{name: "gh release download", script: "gh release download v1.0.0", network: true},
|
||||
{name: "no network", script: "ls -la && echo done", network: false},
|
||||
{name: "process pattern is not network", script: `pkill -f "python3 -m http.server 8000"`, network: false},
|
||||
{name: "process listing is not special-cased", script: "ps aux", network: false},
|
||||
|
||||
{name: "unparseable", script: `'unterminated quote`, tooComplex: true},
|
||||
}
|
||||
|
||||
@@ -103,16 +103,12 @@ func TestCommandPlanCarriesSandboxMetadata(t *testing.T) {
|
||||
Policy: DefaultPolicy(),
|
||||
Backend: Backend{Name: BackendUnavailable, Platform: "linux", Fallback: true, Message: "native sandbox unavailable"},
|
||||
})
|
||||
degraded, err := unavailable.BuildCommandPlan(CommandSpec{Name: "/bin/sh", Dir: root})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildCommandPlan unavailable auto plan: %v", err)
|
||||
}
|
||||
if degraded.Wrapped || degraded.EnforcementLevel != EnforcementDegraded || degraded.DowngradeReason != "native sandbox unavailable" {
|
||||
t.Fatalf("unavailable command metadata = %#v, want degraded direct plan", degraded)
|
||||
if _, err := unavailable.BuildCommandPlan(CommandSpec{Name: "/bin/sh", Dir: root}); !errors.Is(err, errNativeSandboxUnavailable) {
|
||||
t.Fatalf("BuildCommandPlan unavailable error = %v, want native sandbox unavailable", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnavailableBackendsDegradeForTargetPlatforms(t *testing.T) {
|
||||
func TestUnavailableFailClosedForTargetPlatforms(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
policy := DefaultPolicy()
|
||||
tests := []struct {
|
||||
@@ -140,12 +136,9 @@ func TestUnavailableBackendsDegradeForTargetPlatforms(t *testing.T) {
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
engine := NewEngine(EngineOptions{WorkspaceRoot: root, Policy: policy, Backend: test.backend})
|
||||
plan, err := engine.BuildCommandPlan(CommandSpec{Name: "/bin/sh", Dir: root})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildCommandPlan degraded auto plan: %v", err)
|
||||
}
|
||||
if plan.Wrapped || plan.EnforcementLevel != EnforcementDegraded || !plan.RequiresPlatformSandbox {
|
||||
t.Fatalf("BuildCommandPlan = %#v, want degraded direct plan requiring platform sandbox metadata", plan)
|
||||
_, err := engine.BuildCommandPlan(CommandSpec{Name: "/bin/sh", Dir: root})
|
||||
if !errors.Is(err, errNativeSandboxUnavailable) {
|
||||
t.Fatalf("BuildCommandPlan error = %v, want native sandbox unavailable", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -40,34 +40,6 @@ func TestSandboxedBashAutoAllowedWhenSandboxActive(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSandboxedBashRequireEscalatedStillPromptsWhenSandboxActive(t *testing.T) {
|
||||
engine := sandboxedShellEngine(t, nativeWrappingBackend)
|
||||
request := bashRequest()
|
||||
request.Args["sandbox_permissions"] = "require_escalated"
|
||||
|
||||
decision := engine.Evaluate(context.Background(), request)
|
||||
if decision.Action != ActionPrompt || decision.Reason != ReasonEscalatedSandboxRequired {
|
||||
t.Fatalf("decision = %#v, want require_escalated prompt", decision)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSandboxedBashProcessListingAutoAllowedWhenSandboxActive(t *testing.T) {
|
||||
engine := sandboxedShellEngine(t, nativeWrappingBackend)
|
||||
request := bashRequest()
|
||||
request.Args["command"] = "ps aux"
|
||||
|
||||
decision := engine.Evaluate(context.Background(), request)
|
||||
if decision.Action != ActionAllow {
|
||||
t.Fatalf("decision = %#v, want allow (sandbox active)", decision)
|
||||
}
|
||||
if len(decision.Risk.Categories) != 1 || !HasRiskCategory(decision.Risk, "shell") {
|
||||
t.Fatalf("risk = %#v, want only shell category", decision.Risk)
|
||||
}
|
||||
if !decision.AutoAllowed {
|
||||
t.Fatalf("sandboxed shell command should be auto-allowed: %#v", decision)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSandboxedBashStillPromptsWithoutSandbox(t *testing.T) {
|
||||
engine := sandboxedShellEngine(t, Backend{Name: BackendUnavailable})
|
||||
decision := engine.Evaluate(context.Background(), bashRequest())
|
||||
|
||||
@@ -46,7 +46,7 @@ func NewEngine(options EngineOptions) *Engine {
|
||||
}
|
||||
}
|
||||
if scope == nil && workspaceRoot != "" {
|
||||
scope = newScopeBestEffort(workspaceRoot)
|
||||
scope = &Scope{workspaceRoot: normalizeWorkspaceRootBestEffort(workspaceRoot)}
|
||||
}
|
||||
return &Engine{
|
||||
workspaceRoot: workspaceRoot,
|
||||
@@ -170,19 +170,6 @@ func (engine *Engine) effectiveNetworkMode(policy Policy) NetworkMode {
|
||||
return NormalizeNetworkMode(policy.Network)
|
||||
}
|
||||
|
||||
// UnsandboxedExecutionAllowed reports whether an escalated shell attempt may
|
||||
// bypass the native sandbox without dropping active denied-read restrictions.
|
||||
func (engine *Engine) UnsandboxedExecutionAllowed() bool {
|
||||
if engine == nil {
|
||||
return true
|
||||
}
|
||||
policy := engine.effectivePolicy(engine.policy)
|
||||
if policy.Mode == ModeDisabled {
|
||||
return true
|
||||
}
|
||||
return len(normalizeProfilePaths(policy.DenyRead)) == 0
|
||||
}
|
||||
|
||||
// toolNetworkExempt reports whether a request is exempt from the engine-level
|
||||
// network deny because it is a first-party, in-process network tool. Such tools
|
||||
// do not use sandboxed shell egress; they keep their own SSRF/host safeguards. A
|
||||
@@ -203,15 +190,7 @@ func (engine *Engine) scopeFor(requestRoot string) *Scope {
|
||||
if engine.scope != nil && requestRoot == engine.workspaceRoot {
|
||||
return engine.scope
|
||||
}
|
||||
return newScopeBestEffort(requestRoot)
|
||||
}
|
||||
|
||||
func newScopeBestEffort(workspaceRoot string) *Scope {
|
||||
scope, err := NewScope(workspaceRoot, nil)
|
||||
if err == nil {
|
||||
return scope
|
||||
}
|
||||
return &Scope{workspaceRoot: normalizeWorkspaceRootBestEffort(workspaceRoot)}
|
||||
return &Scope{workspaceRoot: requestRoot}
|
||||
}
|
||||
|
||||
// shellSandboxActive reports whether a native wrapping sandbox would actually
|
||||
@@ -361,16 +340,6 @@ func (engine *Engine) Evaluate(ctx context.Context, request Request) Decision {
|
||||
return deny(request, risk, BlockDestructiveCommand, "", "destructive shell command requires approval", false)
|
||||
}
|
||||
}
|
||||
if request.SideEffect == SideEffectShell && requestRequiresEscalatedSandbox(request) {
|
||||
if !request.PermissionGranted && request.PermissionMode != PermissionUnsafe {
|
||||
return Decision{Action: ActionPrompt, Risk: risk, Reason: ReasonEscalatedSandboxRequired}
|
||||
}
|
||||
// Unsafe mode may auto-allow ordinary shell prompts, but it must not
|
||||
// silently convert a sandboxed shell run into an unsandboxed one.
|
||||
if !request.PermissionGranted {
|
||||
return deny(request, risk, BlockDeniedPermission, "", ReasonEscalatedSandboxRequired, false)
|
||||
}
|
||||
}
|
||||
if persistentAllow != nil {
|
||||
return Decision{
|
||||
Action: ActionAllow,
|
||||
@@ -411,10 +380,6 @@ func (engine *Engine) Evaluate(ctx context.Context, request Request) Decision {
|
||||
return Decision{Action: ActionPrompt, Risk: risk, Reason: permissionReason(request)}
|
||||
}
|
||||
|
||||
func requestRequiresEscalatedSandbox(request Request) bool {
|
||||
return strings.TrimSpace(firstArgString(request.Args, "sandbox_permissions")) == "require_escalated"
|
||||
}
|
||||
|
||||
func (engine *Engine) Grant(input GrantInput) (Grant, error) {
|
||||
if engine == nil || engine.store == nil {
|
||||
return Grant{}, errors.New("sandbox grant store is not configured")
|
||||
|
||||
@@ -70,23 +70,6 @@ func TestEngineBashAllowGrantDoesNotBypassNetworkPrompt(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnsandboxedExecutionAllowedPreservesDeniedReads(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
policy := DefaultPolicy()
|
||||
policy.DenyRead = []string{filepath.Join(root, "secret.txt")}
|
||||
engine := NewEngine(EngineOptions{WorkspaceRoot: root, Policy: policy})
|
||||
|
||||
if engine.UnsandboxedExecutionAllowed() {
|
||||
t.Fatal("denied-read policy must not allow unsandboxed escalation")
|
||||
}
|
||||
|
||||
policy.DenyRead = nil
|
||||
engine = NewEngine(EngineOptions{WorkspaceRoot: root, Policy: policy})
|
||||
if !engine.UnsandboxedExecutionAllowed() {
|
||||
t.Fatal("policy without denied reads should allow unsandboxed escalation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEngineEvaluatesReadPromptAndPersistentDecisions(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
store, err := NewGrantStore(StoreOptions{
|
||||
@@ -239,7 +222,7 @@ func TestEngineAutoAllowsWorkspaceFileMutationTools(t *testing.T) {
|
||||
SideEffect: SideEffectWrite,
|
||||
Permission: PermissionPrompt,
|
||||
PermissionMode: PermissionModeAsk,
|
||||
Args: map[string]any{"path": outsideDefaultTempPath(root, "escape.txt")},
|
||||
Args: map[string]any{"path": filepath.Join(t.TempDir(), "escape.txt")},
|
||||
})
|
||||
if outside.Action != ActionPrompt || outside.Block == nil || outside.Block.Code != BlockOutsideWorkspace || !outside.Block.Recoverable {
|
||||
t.Fatalf("outside workspace mutation decision = %#v, want recoverable prompt", outside)
|
||||
@@ -325,7 +308,7 @@ func TestEngineSessionGrantDoesNotMatchSiblingFile(t *testing.T) {
|
||||
|
||||
func TestEnginePersistentGrantAllowsPromptableOutsideWorkspacePath(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
outside := outsideDefaultTempPath(root, "escape.txt")
|
||||
outside := filepath.Join(t.TempDir(), "escape.txt")
|
||||
store, err := NewGrantStore(StoreOptions{
|
||||
FilePath: filepath.Join(t.TempDir(), "sandbox-grants.json"),
|
||||
Now: fixedSandboxTime("2026-06-05T14:00:00Z"),
|
||||
@@ -362,7 +345,7 @@ func TestEnginePersistentGrantAllowsPromptableOutsideWorkspacePath(t *testing.T)
|
||||
|
||||
func TestEnginePersistentGrantDoesNotBypassSymlinkTraversal(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
outside := outsideDefaultTempPath(root, "symlink-target")
|
||||
outside := t.TempDir()
|
||||
if err := os.Symlink(outside, filepath.Join(root, "linked")); err != nil {
|
||||
t.Skipf("symlink unavailable: %v", err)
|
||||
}
|
||||
@@ -401,7 +384,7 @@ func TestEnginePersistentGrantDoesNotBypassSymlinkTraversal(t *testing.T) {
|
||||
|
||||
func TestEngineDeniesAutoAllowedSymlinkEscape(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
outside := outsideDefaultTempPath(root, "symlink-target")
|
||||
outside := t.TempDir()
|
||||
if err := os.Symlink(outside, filepath.Join(root, "linked")); err != nil {
|
||||
t.Skipf("symlink unavailable: %v", err)
|
||||
}
|
||||
@@ -460,7 +443,7 @@ func TestEngineGrantScopesWebFetchToHost(t *testing.T) {
|
||||
|
||||
func TestEngineDeniesOutOfWorkspacePaths(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
outside := outsideDefaultTempPath(root, "escape.txt")
|
||||
outside := filepath.Join(t.TempDir(), "escape.txt")
|
||||
engine := NewEngine(EngineOptions{WorkspaceRoot: root, Policy: DefaultPolicy()})
|
||||
|
||||
decision := engine.Evaluate(context.Background(), Request{
|
||||
@@ -484,7 +467,7 @@ func TestEngineDeniesOutOfWorkspacePaths(t *testing.T) {
|
||||
|
||||
func TestEnginePrecheckReportsBlocksBeforeExecution(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
outside := outsideDefaultTempPath(root, "escape.txt")
|
||||
outside := filepath.Join(t.TempDir(), "escape.txt")
|
||||
engine := NewEngine(EngineOptions{WorkspaceRoot: root, Policy: DefaultPolicy()})
|
||||
|
||||
// A denied request reports its block without running anything.
|
||||
@@ -519,7 +502,7 @@ func TestEnginePrecheckReportsBlocksBeforeExecution(t *testing.T) {
|
||||
|
||||
func TestEngineDeniesWorkspaceSymlinkTraversal(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
outside := outsideDefaultTempPath(root, "symlink-target")
|
||||
outside := t.TempDir()
|
||||
if err := os.Symlink(outside, filepath.Join(root, "linked")); err != nil {
|
||||
t.Skipf("symlink unavailable: %v", err)
|
||||
}
|
||||
@@ -642,7 +625,7 @@ func TestEngineReportsContextCancellation(t *testing.T) {
|
||||
|
||||
func TestEvaluateOverrideRootDoesNotInheritEngineScope(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
extra := tempDirOutsideDefaultTemp(t)
|
||||
extra := t.TempDir()
|
||||
scope, err := NewScope(workspace, []string{extra})
|
||||
if err != nil {
|
||||
t.Fatalf("NewScope: %v", err)
|
||||
@@ -702,7 +685,7 @@ func promptWorkspaceWritePolicy() Policy {
|
||||
|
||||
func TestEvaluateAllowsWritesInsideExtraScopeRoot(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
extra := tempDirOutsideDefaultTemp(t)
|
||||
extra := t.TempDir()
|
||||
scope, err := NewScope(workspace, []string{extra})
|
||||
if err != nil {
|
||||
t.Fatalf("NewScope: %v", err)
|
||||
@@ -730,7 +713,7 @@ func TestEvaluateAllowsWritesInsideExtraScopeRoot(t *testing.T) {
|
||||
ToolName: "write_file",
|
||||
SideEffect: SideEffectWrite,
|
||||
Permission: PermissionAllow,
|
||||
Args: map[string]any{"path": outsideDefaultTempPath(workspace, "escape.txt")},
|
||||
Args: map[string]any{"path": filepath.Join(t.TempDir(), "escape.txt")},
|
||||
})
|
||||
if outside.Action != ActionPrompt || outside.Block == nil || !outside.Block.Recoverable {
|
||||
t.Fatalf("outside write Action=%q, want recoverable prompt with block", outside.Action)
|
||||
@@ -747,7 +730,7 @@ func TestEvaluateAllowsWritesInsideExtraScopeRoot(t *testing.T) {
|
||||
// skip, turning the engine into an escape hatch.
|
||||
func TestNewEngineDerivesWorkspaceRootFromScope(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
extra := tempDirOutsideDefaultTemp(t)
|
||||
extra := t.TempDir()
|
||||
scope, err := NewScope(workspace, []string{extra})
|
||||
if err != nil {
|
||||
t.Fatalf("NewScope: %v", err)
|
||||
@@ -764,7 +747,7 @@ func TestNewEngineDerivesWorkspaceRootFromScope(t *testing.T) {
|
||||
ToolName: "write_file",
|
||||
SideEffect: SideEffectWrite,
|
||||
Permission: PermissionAllow,
|
||||
Args: map[string]any{"path": outsideDefaultTempPath(workspace, "escape.txt")},
|
||||
Args: map[string]any{"path": filepath.Join(t.TempDir(), "escape.txt")},
|
||||
})
|
||||
if outside.Action != ActionPrompt || outside.Block == nil {
|
||||
t.Fatalf("scope-only engine, out-of-scope write Action=%q, want prompt with block", outside.Action)
|
||||
@@ -781,31 +764,3 @@ func TestNewEngineDerivesWorkspaceRootFromScope(t *testing.T) {
|
||||
t.Fatalf("scope-only engine, in-workspace write Action=%q (%s), want allow", inside.Action, inside.Reason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateAllowsWritesInsideDefaultTempRoot(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
tempRoot := t.TempDir()
|
||||
t.Setenv("TMPDIR", tempRoot)
|
||||
scope, err := NewScope(workspace, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("NewScope: %v", err)
|
||||
}
|
||||
engine := NewEngine(EngineOptions{
|
||||
WorkspaceRoot: workspace,
|
||||
Policy: DefaultPolicy(),
|
||||
Scope: scope,
|
||||
})
|
||||
|
||||
decision := engine.Evaluate(context.Background(), Request{
|
||||
ToolName: "write_file",
|
||||
SideEffect: SideEffectWrite,
|
||||
Permission: PermissionAllow,
|
||||
Args: map[string]any{"path": filepath.Join(tempRoot, "zero-toolcheck", "go.mod")},
|
||||
})
|
||||
if decision.Action != ActionAllow {
|
||||
t.Fatalf("temp-root write Action=%q (%s), want allow", decision.Action, decision.Reason)
|
||||
}
|
||||
if HasRiskCategory(decision.Risk, "out_of_workspace") {
|
||||
t.Fatalf("temp-root write risk=%v, must not be out_of_workspace", decision.Risk)
|
||||
}
|
||||
}
|
||||
@@ -41,11 +41,6 @@ var scopeArgKeys = []struct {
|
||||
// no scoped argument is present, the value is not a string, or the value points
|
||||
// at the workspace root (".") -- in those cases the grant is plainly tool-wide.
|
||||
func DeriveScope(toolName string, args map[string]any) (string, ScopeKind) {
|
||||
if toolName == "browser_open" {
|
||||
if host, ok := deriveBrowserHostScope(args["url"]); ok {
|
||||
return host, ScopeHost
|
||||
}
|
||||
}
|
||||
if toolName == "web_fetch" {
|
||||
if host, ok := deriveHostScope(args["url"]); ok {
|
||||
return host, ScopeHost
|
||||
@@ -83,70 +78,6 @@ func deriveHostScope(value any) (string, bool) {
|
||||
return host, host != ""
|
||||
}
|
||||
|
||||
func deriveBrowserHostScope(value any) (string, bool) {
|
||||
raw, ok := value.(string)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
if trimmed != "" && !strings.Contains(trimmed, "://") {
|
||||
if !browserURLLooksLikeBareHost(trimmed) {
|
||||
return "", false
|
||||
}
|
||||
trimmed = "https://" + trimmed
|
||||
}
|
||||
return deriveHostScope(trimmed)
|
||||
}
|
||||
|
||||
func browserURLLooksLikeBareHost(raw string) bool {
|
||||
if raw == "" || strings.HasPrefix(raw, "//") {
|
||||
return false
|
||||
}
|
||||
authority := raw
|
||||
if cut := strings.IndexAny(authority, "/?#"); cut >= 0 {
|
||||
authority = authority[:cut]
|
||||
}
|
||||
if authority == "" || strings.Contains(authority, "@") {
|
||||
return false
|
||||
}
|
||||
host := authority
|
||||
if strings.HasPrefix(authority, "[") {
|
||||
end := strings.Index(authority, "]")
|
||||
if end <= 1 {
|
||||
return false
|
||||
}
|
||||
host = authority[1:end]
|
||||
if rest := authority[end+1:]; rest != "" {
|
||||
if !strings.HasPrefix(rest, ":") || !isDecimalPort(rest[1:]) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
} else if strings.Contains(authority, ":") {
|
||||
before, after, _ := strings.Cut(authority, ":")
|
||||
if before == "" || !isDecimalPort(after) {
|
||||
return false
|
||||
}
|
||||
host = before
|
||||
}
|
||||
host = strings.TrimSpace(host)
|
||||
if host == "" {
|
||||
return false
|
||||
}
|
||||
return host == "localhost" || strings.Contains(host, ".") || net.ParseIP(host) != nil
|
||||
}
|
||||
|
||||
func isDecimalPort(raw string) bool {
|
||||
if raw == "" {
|
||||
return false
|
||||
}
|
||||
for _, r := range raw {
|
||||
if r < '0' || r > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// resolveScopeAbs converts a raw scope to an absolute, cleaned path. A relative
|
||||
// scope is anchored to workspaceRoot; when workspaceRoot is empty it falls back
|
||||
// to filepath.Abs (process working directory). An empty scope resolves to "".
|
||||
|
||||
@@ -140,11 +140,6 @@ func TestDeriveScope(t *testing.T) {
|
||||
{name: "whitespace path is tool-wide", tool: "write_file", args: map[string]any{"path": " "}, wantRaw: "", wantKind: ScopeToolWide},
|
||||
{name: "web fetch url host", tool: "web_fetch", args: map[string]any{"url": "https://Example.COM:443/path?q=1"}, wantRaw: "example.com", wantKind: ScopeHost},
|
||||
{name: "web fetch invalid url is tool-wide", tool: "web_fetch", args: map[string]any{"url": "://bad"}, wantRaw: "", wantKind: ScopeToolWide},
|
||||
{name: "browser open url host", tool: "browser_open", args: map[string]any{"url": "https://Example.COM/path?q=1"}, wantRaw: "example.com", wantKind: ScopeHost},
|
||||
{name: "browser open bare host", tool: "browser_open", args: map[string]any{"url": "Example.COM/path"}, wantRaw: "example.com", wantKind: ScopeHost},
|
||||
{name: "browser open loopback bare host with port", tool: "browser_open", args: map[string]any{"url": "localhost:8080/path"}, wantRaw: "localhost", wantKind: ScopeHost},
|
||||
{name: "browser open about blank is tool-wide", tool: "browser_open", args: map[string]any{"url": "about:blank"}, wantRaw: "", wantKind: ScopeToolWide},
|
||||
{name: "browser open javascript url is tool-wide", tool: "browser_open", args: map[string]any{"url": "javascript:alert(1)"}, wantRaw: "", wantKind: ScopeToolWide},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
|
||||
@@ -96,9 +96,8 @@ func TestSeatbeltProfileAllowsUserToolAncestorMetadata(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// On macOS the sandbox environment preserves the caller env while still exposing
|
||||
// the Homebrew bin dir so `python3`/`node` resolve to the user's install, not a
|
||||
// system stub.
|
||||
// On macOS the scrubbed sandbox environment must still expose the Homebrew bin
|
||||
// dir so `python3`/`node` resolve to the user's install, not a system stub.
|
||||
func TestSandboxEnvironmentExposesHomebrewOnMac(t *testing.T) {
|
||||
if runtime.GOOS != "darwin" {
|
||||
t.Skip("PATH augmentation is macOS-only")
|
||||
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -159,17 +158,15 @@ func BuildLinuxSandboxBwrapArgs(options LinuxSandboxBwrapOptions) ([]string, err
|
||||
args := []string{
|
||||
"--new-session",
|
||||
"--die-with-parent",
|
||||
"--unshare-user",
|
||||
"--unshare-pid",
|
||||
"--unshare-ipc",
|
||||
"--unshare-uts",
|
||||
}
|
||||
args = append(args, linuxBwrapFilesystemArgs(config.PermissionProfile)...)
|
||||
if pathExists(helperPath) {
|
||||
args = append(args, "--ro-bind", helperPath, helperPath)
|
||||
}
|
||||
args = append(args,
|
||||
"--unshare-user",
|
||||
"--unshare-pid",
|
||||
)
|
||||
// Keep IPC and UTS shared for compatibility with the host CLI environment;
|
||||
// network isolation is still applied below when the policy denies egress.
|
||||
if shouldUnshareLinuxNetwork(config.PermissionProfile.Network) {
|
||||
args = append(args, "--unshare-net")
|
||||
}
|
||||
@@ -177,7 +174,8 @@ func BuildLinuxSandboxBwrapArgs(options LinuxSandboxBwrapOptions) ([]string, err
|
||||
args = append(args, "--proc", "/proc")
|
||||
}
|
||||
args = append(args, "--chdir", commandCWD)
|
||||
for _, env := range linuxHelperSandboxEnvironmentOverrides(config.PermissionProfile) {
|
||||
args = append(args, "--clearenv")
|
||||
for _, env := range linuxHelperSandboxEnvironment(config.PermissionProfile, commandCWD) {
|
||||
key, value, ok := strings.Cut(env, "=")
|
||||
if ok {
|
||||
args = append(args, "--setenv", key, value)
|
||||
@@ -191,10 +189,10 @@ func BuildLinuxSandboxBwrapArgs(options LinuxSandboxBwrapOptions) ([]string, err
|
||||
func linuxBwrapFilesystemArgs(profile PermissionProfile) []string {
|
||||
fs := profile.FileSystem
|
||||
if fs.Kind == FileSystemUnrestricted {
|
||||
// Disabled filesystem policy means no write jail: expose the host root
|
||||
// read-write, including the host /dev tree, rather than synthesizing a
|
||||
// restricted bubblewrap filesystem.
|
||||
args := []string{"--bind", "/", "/"}
|
||||
args := []string{"--ro-bind", "/", "/", "--dev", "/dev"}
|
||||
if fs.AllowTemp {
|
||||
args = append(args, "--tmpfs", "/tmp")
|
||||
}
|
||||
for _, root := range fs.WriteRoots {
|
||||
if pathExists(root.Root) {
|
||||
args = append(args, "--bind", root.Root, root.Root)
|
||||
@@ -203,26 +201,21 @@ func linuxBwrapFilesystemArgs(profile PermissionProfile) []string {
|
||||
return args
|
||||
}
|
||||
|
||||
args := []string{}
|
||||
if linuxProfileHasFullReadRoot(fs) {
|
||||
args = append(args, "--ro-bind", "/", "/", "--dev", "/dev")
|
||||
} else {
|
||||
args = append(args, "--tmpfs", "/", "--dev", "/dev")
|
||||
if fs.IncludePlatformRoots {
|
||||
for _, root := range linuxPlatformReadRoots() {
|
||||
args = append(args, "--ro-bind", root, root)
|
||||
}
|
||||
args := []string{"--tmpfs", "/", "--dev", "/dev"}
|
||||
if fs.IncludePlatformRoots {
|
||||
for _, root := range linuxPlatformReadRoots() {
|
||||
args = append(args, "--ro-bind", root, root)
|
||||
}
|
||||
for _, root := range fs.ReadRoots {
|
||||
if pathExists(root) {
|
||||
args = append(args, "--ro-bind", root, root)
|
||||
}
|
||||
}
|
||||
for _, root := range fs.ReadRoots {
|
||||
if pathExists(root) {
|
||||
args = append(args, "--ro-bind", root, root)
|
||||
}
|
||||
}
|
||||
if fs.AllowTemp {
|
||||
fs.WriteRoots = linuxWriteRootsWithTemp(fs)
|
||||
args = append(args, "--tmpfs", "/tmp")
|
||||
}
|
||||
for _, root := range linuxSortedWriteRoots(fs.WriteRoots) {
|
||||
for _, root := range fs.WriteRoots {
|
||||
if !pathExists(root.Root) {
|
||||
continue
|
||||
}
|
||||
@@ -243,52 +236,6 @@ func linuxBwrapFilesystemArgs(profile PermissionProfile) []string {
|
||||
return args
|
||||
}
|
||||
|
||||
func linuxWriteRootsWithTemp(fs FileSystemPolicy) []WritableRoot {
|
||||
roots := append([]WritableRoot{}, fs.WriteRoots...)
|
||||
for _, tempRoot := range defaultTempWriteRoots() {
|
||||
found := false
|
||||
for _, root := range roots {
|
||||
if filepath.Clean(root.Root) == filepath.Clean(tempRoot) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
roots = append(roots, WritableRoot{Root: tempRoot})
|
||||
}
|
||||
}
|
||||
return roots
|
||||
}
|
||||
|
||||
func linuxSortedWriteRoots(roots []WritableRoot) []WritableRoot {
|
||||
sorted := append([]WritableRoot{}, roots...)
|
||||
sort.SliceStable(sorted, func(i, j int) bool {
|
||||
return pathDepth(sorted[i].Root) < pathDepth(sorted[j].Root)
|
||||
})
|
||||
return sorted
|
||||
}
|
||||
|
||||
func pathDepth(path string) int {
|
||||
cleaned := filepath.Clean(path)
|
||||
if cleaned == "" || filepath.Dir(cleaned) == cleaned {
|
||||
return 0
|
||||
}
|
||||
trimmed := strings.Trim(cleaned, string(filepath.Separator))
|
||||
if trimmed == "" {
|
||||
return 0
|
||||
}
|
||||
return strings.Count(trimmed, string(filepath.Separator)) + 1
|
||||
}
|
||||
|
||||
func linuxProfileHasFullReadRoot(fs FileSystemPolicy) bool {
|
||||
for _, root := range fs.ReadRoots {
|
||||
if filepath.Clean(root) == string(filepath.Separator) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func linuxPlatformReadRoots() []string {
|
||||
candidates := []string{"/bin", "/sbin", "/usr", "/etc", "/lib", "/lib64", "/nix/store", "/run/current-system/sw"}
|
||||
roots := make([]string, 0, len(candidates))
|
||||
@@ -326,16 +273,16 @@ func shouldUnshareLinuxNetwork(policy NetworkPolicy) bool {
|
||||
return NormalizeNetworkMode(policy.Mode) == NetworkDeny
|
||||
}
|
||||
|
||||
func linuxHelperSandboxEnvironment(profile PermissionProfile, base []string) []string {
|
||||
return upsertEnvList(base, linuxHelperSandboxEnvironmentOverrides(profile)...)
|
||||
}
|
||||
|
||||
func linuxHelperSandboxEnvironmentOverrides(profile PermissionProfile) []string {
|
||||
return []string{
|
||||
func linuxHelperSandboxEnvironment(profile PermissionProfile, home string) []string {
|
||||
env := []string{
|
||||
"HOME=" + home,
|
||||
"PATH=" + firstEnv("PATH", defaultPath()),
|
||||
"TERM=" + firstEnv("TERM", "dumb"),
|
||||
EnvSandboxBackend + "=" + string(linuxSandboxBackendEnv),
|
||||
"ZERO_SANDBOX_NETWORK=" + string(profile.Network.Mode),
|
||||
EnvSandboxed + "=1",
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
func pathExists(path string) bool {
|
||||
|
||||
@@ -10,11 +10,6 @@ import (
|
||||
"syscall"
|
||||
)
|
||||
|
||||
var (
|
||||
applyUnixSocketBlockFilter = ApplyUnixSocketBlock
|
||||
applyLinuxNetworkDenyFilter = ApplyLinuxNetworkDeny
|
||||
)
|
||||
|
||||
func RunLinuxSandboxHelper(args []string, stderr io.Writer) int {
|
||||
config, err := ParseLinuxSandboxHelperArgs(args)
|
||||
if err != nil {
|
||||
@@ -69,14 +64,8 @@ func runLinuxSandboxInnerStage(config LinuxSandboxHelperConfig, stderr io.Writer
|
||||
fmt.Fprintln(stderr, LinuxSandboxHelperName+": inner seccomp stage is incompatible with Landlock mode")
|
||||
return 2
|
||||
}
|
||||
if shouldUnshareLinuxNetwork(config.PermissionProfile.Network) {
|
||||
if err := applyLinuxNetworkDenyFilter(); err != nil {
|
||||
fmt.Fprintln(stderr, LinuxSandboxHelperName+": apply network deny: "+err.Error())
|
||||
return 125
|
||||
}
|
||||
}
|
||||
if config.BlockUnixSockets {
|
||||
if err := applyUnixSocketBlockFilter(); err != nil {
|
||||
if err := ApplyUnixSocketBlock(); err != nil {
|
||||
fmt.Fprintln(stderr, LinuxSandboxHelperName+": warning: "+err.Error()+"; running without the Unix-socket filter")
|
||||
}
|
||||
}
|
||||
@@ -102,7 +91,7 @@ func runLinuxSandboxLandlockStage(config LinuxSandboxHelperConfig, stderr io.Wri
|
||||
fmt.Fprintln(stderr, LinuxSandboxHelperName+": "+err.Error())
|
||||
return 127
|
||||
}
|
||||
if err := syscall.Exec(binary, config.Command, linuxHelperSandboxEnvironment(config.PermissionProfile, os.Environ())); err != nil {
|
||||
if err := syscall.Exec(binary, config.Command, linuxHelperSandboxEnvironment(config.PermissionProfile, config.CommandCWD)); err != nil {
|
||||
fmt.Fprintln(stderr, LinuxSandboxHelperName+": exec command: "+err.Error())
|
||||
return 126
|
||||
}
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
//go:build linux
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLinuxSandboxInnerStageAppliesNetworkDenySeccomp(t *testing.T) {
|
||||
originalNetworkDeny := applyLinuxNetworkDenyFilter
|
||||
originalUnixBlock := applyUnixSocketBlockFilter
|
||||
t.Cleanup(func() {
|
||||
applyLinuxNetworkDenyFilter = originalNetworkDeny
|
||||
applyUnixSocketBlockFilter = originalUnixBlock
|
||||
})
|
||||
|
||||
networkDenyCalls := 0
|
||||
unixBlockCalls := 0
|
||||
applyLinuxNetworkDenyFilter = func() error {
|
||||
networkDenyCalls++
|
||||
return nil
|
||||
}
|
||||
applyUnixSocketBlockFilter = func() error {
|
||||
unixBlockCalls++
|
||||
return nil
|
||||
}
|
||||
|
||||
var stderr bytes.Buffer
|
||||
code := runLinuxSandboxInnerStage(LinuxSandboxHelperConfig{
|
||||
PermissionProfile: PermissionProfile{Network: NetworkPolicy{Mode: NetworkDeny}},
|
||||
BlockUnixSockets: true,
|
||||
Command: []string{"definitely-not-a-real-zero-test-command"},
|
||||
}, &stderr)
|
||||
|
||||
if code != 127 {
|
||||
t.Fatalf("exit code = %d, want lookup failure 127 after filters; stderr=%s", code, stderr.String())
|
||||
}
|
||||
if networkDenyCalls != 1 {
|
||||
t.Fatalf("network deny filter calls = %d, want 1", networkDenyCalls)
|
||||
}
|
||||
if unixBlockCalls != 1 {
|
||||
t.Fatalf("unix socket filter calls = %d, want 1", unixBlockCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLinuxSandboxInnerStageSkipsNetworkDenySeccompWhenAllowed(t *testing.T) {
|
||||
originalNetworkDeny := applyLinuxNetworkDenyFilter
|
||||
t.Cleanup(func() {
|
||||
applyLinuxNetworkDenyFilter = originalNetworkDeny
|
||||
})
|
||||
|
||||
applyLinuxNetworkDenyFilter = func() error {
|
||||
return errors.New("network deny filter should not run")
|
||||
}
|
||||
|
||||
var stderr bytes.Buffer
|
||||
code := runLinuxSandboxInnerStage(LinuxSandboxHelperConfig{
|
||||
PermissionProfile: PermissionProfile{Network: NetworkPolicy{Mode: NetworkAllow}},
|
||||
Command: []string{"definitely-not-a-real-zero-test-command"},
|
||||
}, &stderr)
|
||||
|
||||
if code != 127 {
|
||||
t.Fatalf("exit code = %d, want lookup failure 127 without network filter; stderr=%s", code, stderr.String())
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"runtime"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -117,7 +116,6 @@ func TestBuildLinuxSandboxBwrapArgsWrapsInnerSeccompStage(t *testing.T) {
|
||||
{"--unshare-user"},
|
||||
{"--unshare-pid"},
|
||||
{"--unshare-net"},
|
||||
{"--ro-bind", "/", "/"},
|
||||
{"--chdir", "/workspace"},
|
||||
{"--setenv", EnvSandboxBackend, string(BackendLinuxBwrap)},
|
||||
{"--ro-bind", helperPath, helperPath},
|
||||
@@ -128,20 +126,6 @@ func TestBuildLinuxSandboxBwrapArgsWrapsInnerSeccompStage(t *testing.T) {
|
||||
} {
|
||||
assertArgsContainSequence(t, bwrapArgs, want...)
|
||||
}
|
||||
if argsContainSequence(bwrapArgs, "--tmpfs", "/") {
|
||||
t.Fatalf("default workspace-write profile must not start from an empty root: %#v", bwrapArgs)
|
||||
}
|
||||
if argsContainSequence(bwrapArgs, "--tmpfs", "/tmp") {
|
||||
t.Fatalf("default workspace-write profile must not replace host /tmp: %#v", bwrapArgs)
|
||||
}
|
||||
if stringSliceContains(bwrapArgs, "--clearenv") {
|
||||
t.Fatalf("Linux bwrap args must preserve caller environment like upstream: %#v", bwrapArgs)
|
||||
}
|
||||
for _, unwanted := range []string{"--unshare-ipc", "--unshare-uts"} {
|
||||
if stringSliceContains(bwrapArgs, unwanted) {
|
||||
t.Fatalf("Linux bwrap args should match upstream namespace set; found %s in %#v", unwanted, bwrapArgs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildLinuxSandboxBwrapArgsKeepsHostNetworkWhenAllowed(t *testing.T) {
|
||||
@@ -177,114 +161,6 @@ func TestBuildLinuxSandboxBwrapArgsKeepsHostNetworkWhenAllowed(t *testing.T) {
|
||||
assertArgsContainSequence(t, bwrapArgs, "--setenv", "ZERO_SANDBOX_NETWORK", string(NetworkAllow))
|
||||
}
|
||||
|
||||
func TestLinuxBwrapRootReadUsesReadOnlyHostRoot(t *testing.T) {
|
||||
profile := PermissionProfile{
|
||||
FileSystem: FileSystemPolicy{
|
||||
Kind: FileSystemRestricted,
|
||||
ReadRoots: []string{string(filepath.Separator)},
|
||||
WriteRoots: []WritableRoot{{Root: "/workspace"}},
|
||||
IncludePlatformRoots: true,
|
||||
AllowTemp: true,
|
||||
},
|
||||
Network: NetworkPolicy{Mode: NetworkAllow},
|
||||
}
|
||||
|
||||
args := linuxBwrapFilesystemArgs(profile)
|
||||
assertArgsContainSequence(t, args, "--ro-bind", "/", "/")
|
||||
if argsContainSequence(args, "--tmpfs", "/") {
|
||||
t.Fatalf("root-read profile must not start from an empty root: %#v", args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLinuxBwrapTempUsesHostWriteRoots(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("Linux bwrap temp root assertions use Unix paths")
|
||||
}
|
||||
tmpdir := t.TempDir()
|
||||
t.Setenv("TMPDIR", tmpdir)
|
||||
workspace := filepath.Join(tmpdir, "workspace")
|
||||
if err := os.MkdirAll(workspace, 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll workspace: %v", err)
|
||||
}
|
||||
profile := PermissionProfile{
|
||||
FileSystem: FileSystemPolicy{
|
||||
Kind: FileSystemRestricted,
|
||||
ReadRoots: []string{string(filepath.Separator)},
|
||||
WriteRoots: []WritableRoot{{Root: workspace, ProtectedMetadataNames: []string{".git"}}},
|
||||
AllowTemp: true,
|
||||
},
|
||||
Network: NetworkPolicy{Mode: NetworkAllow},
|
||||
}
|
||||
|
||||
args := linuxBwrapFilesystemArgs(profile)
|
||||
if argsContainSequence(args, "--tmpfs", "/tmp") {
|
||||
t.Fatalf("workspace-write temp access must bind host /tmp, not create private tmpfs: %#v", args)
|
||||
}
|
||||
for _, tempRoot := range defaultTempWriteRoots() {
|
||||
if pathExists(tempRoot) {
|
||||
assertArgsContainSequence(t, args, "--bind", tempRoot, tempRoot)
|
||||
}
|
||||
}
|
||||
assertArgsContainSequence(t, args, "--bind", workspace, workspace)
|
||||
|
||||
if runtime.GOOS == "linux" {
|
||||
tmpdirBind := argsSequenceIndex(args, "--bind", tmpdir, tmpdir)
|
||||
workspaceBind := argsSequenceIndex(args, "--bind", workspace, workspace)
|
||||
if tmpdirBind < 0 || workspaceBind < 0 || tmpdirBind > workspaceBind {
|
||||
t.Fatalf("broader temp root must be bound before nested workspace root; args=%#v", args)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLinuxBwrapUnrestrictedFilesystemUsesWritableHostRoot(t *testing.T) {
|
||||
profile := PermissionProfile{
|
||||
FileSystem: FileSystemPolicy{
|
||||
Kind: FileSystemUnrestricted,
|
||||
AllowTemp: true,
|
||||
},
|
||||
Network: NetworkPolicy{Mode: NetworkDeny},
|
||||
}
|
||||
|
||||
args := linuxBwrapFilesystemArgs(profile)
|
||||
assertArgsContainSequence(t, args, "--bind", "/", "/")
|
||||
if argsContainSequence(args, "--ro-bind", "/", "/") {
|
||||
t.Fatalf("unrestricted filesystem profile must not make host root read-only: %#v", args)
|
||||
}
|
||||
if argsContainSequence(args, "--tmpfs", "/tmp") {
|
||||
t.Fatalf("unrestricted filesystem profile must not replace host /tmp: %#v", args)
|
||||
}
|
||||
if argsContainSequence(args, "--dev", "/dev") {
|
||||
t.Fatalf("unrestricted filesystem profile must not replace host /dev: %#v", args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLinuxHelperSandboxEnvironmentPreservesCallerEnv(t *testing.T) {
|
||||
env := linuxHelperSandboxEnvironment(
|
||||
PermissionProfile{Network: NetworkPolicy{Mode: NetworkDeny}},
|
||||
[]string{
|
||||
"PATH=/custom/bin",
|
||||
"HOME=/home/user",
|
||||
EnvSandboxed + "=0",
|
||||
EnvSandboxBackend + "=other",
|
||||
},
|
||||
)
|
||||
|
||||
for _, want := range []string{
|
||||
"PATH=/custom/bin",
|
||||
"HOME=/home/user",
|
||||
EnvSandboxed + "=1",
|
||||
EnvSandboxBackend + "=" + string(BackendLinuxBwrap),
|
||||
"ZERO_SANDBOX_NETWORK=deny",
|
||||
} {
|
||||
if !stringSliceContains(env, want) {
|
||||
t.Fatalf("linux helper env = %#v, missing %q", env, want)
|
||||
}
|
||||
}
|
||||
if stringSliceContains(env, EnvSandboxed+"=0") || stringSliceContains(env, EnvSandboxBackend+"=other") {
|
||||
t.Fatalf("linux helper env did not replace stale sandbox markers: %#v", env)
|
||||
}
|
||||
}
|
||||
|
||||
func indexString(values []string, want string) int {
|
||||
for index, value := range values {
|
||||
if value == want {
|
||||
@@ -293,26 +169,3 @@ func indexString(values []string, want string) int {
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func argsContainSequence(args []string, sequence ...string) bool {
|
||||
return argsSequenceIndex(args, sequence...) >= 0
|
||||
}
|
||||
|
||||
func argsSequenceIndex(args []string, sequence ...string) int {
|
||||
if len(sequence) == 0 {
|
||||
return 0
|
||||
}
|
||||
for index := 0; index <= len(args)-len(sequence); index++ {
|
||||
matched := true
|
||||
for offset, want := range sequence {
|
||||
if args[index+offset] != want {
|
||||
matched = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if matched {
|
||||
return index
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
@@ -76,17 +76,11 @@ type SandboxExecutionRequest struct {
|
||||
}
|
||||
|
||||
func NewSandboxManager(options SandboxManagerOptions) SandboxManager {
|
||||
backend := options.Backend
|
||||
if backend.Name != "" && backend.Platform == "" {
|
||||
backend.Platform = platformForBackendName(backend.Name)
|
||||
}
|
||||
goos := options.GOOS
|
||||
if goos == "" {
|
||||
goos = backend.Platform
|
||||
}
|
||||
if goos == "" {
|
||||
goos = runtime.GOOS
|
||||
}
|
||||
backend := options.Backend
|
||||
if backend.Name == "" {
|
||||
backend = selectPlatformBackend(goos, options.LookupExecutable)
|
||||
}
|
||||
@@ -97,19 +91,6 @@ func NewSandboxManager(options SandboxManagerOptions) SandboxManager {
|
||||
return SandboxManager{goos: goos, backend: backend}
|
||||
}
|
||||
|
||||
func platformForBackendName(name BackendName) string {
|
||||
switch name {
|
||||
case BackendMacOSSeatbelt:
|
||||
return "darwin"
|
||||
case BackendLinuxBwrap, BackendLinuxLandlock, BackendWSL:
|
||||
return "linux"
|
||||
case BackendWindowsRestrictedToken, BackendWindowsElevated:
|
||||
return "windows"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func (manager SandboxManager) Backend() Backend {
|
||||
return manager.backend
|
||||
}
|
||||
@@ -174,6 +155,9 @@ func (manager SandboxManager) BuildExecutionRequest(request SandboxManagerReques
|
||||
if request.ValidateExecution && preference == SandboxPreferenceRequire && backend.SupportLevel() != BackendSupportNative {
|
||||
return SandboxExecutionRequest{}, nativeSandboxUnavailableError(backend)
|
||||
}
|
||||
if request.ValidateExecution && requiresPlatformSandbox && backend.SupportLevel() != BackendSupportNative {
|
||||
return SandboxExecutionRequest{}, nativeSandboxUnavailableError(backend)
|
||||
}
|
||||
// Windows: the OS sandbox needs a one-time elevated `zero sandbox setup` (it
|
||||
// applies WFP network filters + workspace ACLs and writes a marker). Without
|
||||
// it the command runner hard-fails — so on the default preference, DEGRADE to
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"runtime"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -29,17 +28,11 @@ func TestPermissionProfileFromPolicyBuildsWorkspaceWriteProfile(t *testing.T) {
|
||||
if profile.FileSystem.Kind != FileSystemRestricted {
|
||||
t.Fatalf("filesystem kind = %q, want restricted", profile.FileSystem.Kind)
|
||||
}
|
||||
roots := scope.Roots()
|
||||
if len(profile.FileSystem.WriteRoots) != len(roots) {
|
||||
t.Fatalf("write roots = %#v, want scope roots %#v", profile.FileSystem.WriteRoots, roots)
|
||||
if len(profile.FileSystem.WriteRoots) != 2 {
|
||||
t.Fatalf("write roots = %#v, want workspace + extra", profile.FileSystem.WriteRoots)
|
||||
}
|
||||
for i, root := range roots {
|
||||
if profile.FileSystem.WriteRoots[i].Root != root {
|
||||
t.Fatalf("write roots = %#v, want scope roots %#v", profile.FileSystem.WriteRoots, roots)
|
||||
}
|
||||
}
|
||||
if !stringSliceContains(profile.FileSystem.ReadRoots, profileRootPath()) {
|
||||
t.Fatalf("read roots = %#v, want full read root %q", profile.FileSystem.ReadRoots, profileRootPath())
|
||||
if profile.FileSystem.WriteRoots[0].Root != scope.Roots()[0] || profile.FileSystem.WriteRoots[1].Root != scope.Roots()[1] {
|
||||
t.Fatalf("write roots = %#v, want scope roots %#v", profile.FileSystem.WriteRoots, scope.Roots())
|
||||
}
|
||||
if !stringSliceContains(profile.FileSystem.WriteRoots[0].ProtectedMetadataNames, ".git") || !stringSliceContains(profile.FileSystem.WriteRoots[0].ProtectedMetadataNames, ".zero") {
|
||||
t.Fatalf("protected metadata names = %#v, want workspace metadata protected", profile.FileSystem.WriteRoots[0].ProtectedMetadataNames)
|
||||
@@ -55,43 +48,9 @@ func TestPermissionProfileFromPolicyBuildsWorkspaceWriteProfile(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPermissionProfileFromPolicyIncludesDefaultTempWriteRoots(t *testing.T) {
|
||||
tmpdir := t.TempDir()
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Setenv("TEMP", tmpdir)
|
||||
t.Setenv("TMP", tmpdir)
|
||||
} else {
|
||||
t.Setenv("TMPDIR", tmpdir)
|
||||
}
|
||||
workspace := t.TempDir()
|
||||
|
||||
profile := PermissionProfileFromPolicy(workspace, DefaultPolicy(), nil)
|
||||
if !writeRootsContain(profile.FileSystem.WriteRoots, workspace) {
|
||||
t.Fatalf("write roots = %#v, want workspace %q", profile.FileSystem.WriteRoots, workspace)
|
||||
}
|
||||
if !writeRootsContain(profile.FileSystem.WriteRoots, tmpdir) {
|
||||
t.Fatalf("write roots = %#v, want temp root %q", profile.FileSystem.WriteRoots, tmpdir)
|
||||
}
|
||||
if pathExists("/tmp") && !writeRootsContain(profile.FileSystem.WriteRoots, "/tmp") {
|
||||
t.Fatalf("write roots = %#v, want /tmp", profile.FileSystem.WriteRoots)
|
||||
}
|
||||
}
|
||||
|
||||
func writeRootsContain(roots []WritableRoot, want string) bool {
|
||||
want = normalizeProfilePath(want)
|
||||
for _, root := range roots {
|
||||
if normalizeProfilePath(root.Root) == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TestUnknownNetworkModeFailsClosed(t *testing.T) {
|
||||
for _, mode := range []NetworkMode{"scoped", "proxy"} {
|
||||
if got := NormalizeNetworkMode(mode); got != NetworkDeny {
|
||||
t.Fatalf("NormalizeNetworkMode(%s) = %q, want %q", mode, got, NetworkDeny)
|
||||
}
|
||||
if got := NormalizeNetworkMode(NetworkMode("scoped")); got != NetworkDeny {
|
||||
t.Fatalf("NormalizeNetworkMode(scoped) = %q, want %q", got, NetworkDeny)
|
||||
}
|
||||
profile := PermissionProfileFromPolicy(t.TempDir(), Policy{
|
||||
Mode: ModeEnforce,
|
||||
@@ -216,11 +175,11 @@ func TestSandboxManagerBuildsCommandPlanThroughWindowsRunner(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSandboxManagerDegradesUnavailableCommandPlan(t *testing.T) {
|
||||
func TestSandboxManagerRejectsUnavailableCommandPlan(t *testing.T) {
|
||||
policy := DefaultPolicy()
|
||||
backend := Backend{Name: BackendUnavailable, Platform: "windows", Fallback: true, Message: "native sandbox unavailable"}
|
||||
manager := NewSandboxManager(SandboxManagerOptions{GOOS: "windows", Backend: backend})
|
||||
plan, err := manager.BuildCommandPlan(SandboxManagerRequest{
|
||||
_, err := manager.BuildCommandPlan(SandboxManagerRequest{
|
||||
WorkspaceRoot: `C:\workspace`,
|
||||
Command: CommandSpec{Name: "cmd.exe", Args: []string{"/c", "dir"}, Dir: `C:\workspace`},
|
||||
Policy: policy,
|
||||
@@ -228,11 +187,8 @@ func TestSandboxManagerDegradesUnavailableCommandPlan(t *testing.T) {
|
||||
Preference: SandboxPreferenceAuto,
|
||||
ValidateExecution: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildCommandPlan: %v", err)
|
||||
}
|
||||
if plan.Wrapped || plan.EnforcementLevel != EnforcementDegraded || plan.DowngradeReason != "native sandbox unavailable" {
|
||||
t.Fatalf("plan = %#v, want degraded direct plan", plan)
|
||||
if !errors.Is(err, errNativeSandboxUnavailable) {
|
||||
t.Fatalf("BuildCommandPlan error = %v, want native sandbox unavailable", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -280,29 +236,6 @@ func TestSandboxManagerSelectsPlatformBackend(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSandboxManagerInfersPlatformFromExplicitBackend(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
backend BackendName
|
||||
wantGOOS string
|
||||
}{
|
||||
{name: "linux helper", backend: BackendLinuxBwrap, wantGOOS: "linux"},
|
||||
{name: "macos seatbelt", backend: BackendMacOSSeatbelt, wantGOOS: "darwin"},
|
||||
{name: "windows runner", backend: BackendWindowsRestrictedToken, wantGOOS: "windows"},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
manager := NewSandboxManager(SandboxManagerOptions{
|
||||
Backend: Backend{Name: test.backend, Available: true, Executable: "sandbox-helper"},
|
||||
})
|
||||
if manager.goos != test.wantGOOS || manager.backend.Platform != test.wantGOOS {
|
||||
t.Fatalf("manager = %#v, want platform/goos %q", manager, test.wantGOOS)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectBackendDelegatesToSandboxManagerSelection(t *testing.T) {
|
||||
backend := SelectBackend(BackendOptions{
|
||||
GOOS: "linux",
|
||||
@@ -344,7 +277,7 @@ func TestSandboxManagerFailsClosedWhenNativeRequiredAndUnavailable(t *testing.T)
|
||||
Command: CommandSpec{Name: "cmd.exe", Dir: "/workspace"},
|
||||
Policy: policy,
|
||||
Profile: profile,
|
||||
Preference: SandboxPreferenceRequire,
|
||||
Preference: SandboxPreferenceAuto,
|
||||
ValidateExecution: true,
|
||||
})
|
||||
if !errors.Is(err, errNativeSandboxUnavailable) {
|
||||
|
||||
@@ -30,7 +30,7 @@ func NormalizePermission(value Permission) Permission {
|
||||
func NormalizeSideEffect(value SideEffect) SideEffect {
|
||||
normalized := SideEffect(strings.ToLower(strings.TrimSpace(string(value))))
|
||||
switch normalized {
|
||||
case SideEffectRead, SideEffectWrite, SideEffectShell, SideEffectNetwork, SideEffectLocalControl, SideEffectLocalBrowser, SideEffectLocalDesktop, SideEffectLocalTerminal, SideEffectOutOfWorkspace, SideEffectNone:
|
||||
case SideEffectRead, SideEffectWrite, SideEffectShell, SideEffectNetwork, SideEffectOutOfWorkspace, SideEffectNone:
|
||||
return normalized
|
||||
default:
|
||||
return SideEffectOutOfWorkspace
|
||||
|
||||
@@ -84,10 +84,10 @@ func TestWritePrecedenceMatrix(t *testing.T) {
|
||||
ws := resolvedTempDir(t)
|
||||
mkdir(t, filepath.Join(ws, "src"))
|
||||
wsSecret := mkdir(t, filepath.Join(ws, "secret"))
|
||||
ext := tempDirOutsideDefaultTemp(t)
|
||||
ext := resolvedTempDir(t)
|
||||
mkdir(t, filepath.Join(ext, "build"))
|
||||
extProtected := mkdir(t, filepath.Join(ext, "build", "protected"))
|
||||
outside := outsideDefaultTempPath(ws, "outside")
|
||||
outside := resolvedTempDir(t) // never allowed
|
||||
|
||||
scope, err := NewScope(ws, nil)
|
||||
if err != nil {
|
||||
@@ -348,7 +348,7 @@ func TestEvaluateAppliesReadDeny(t *testing.T) {
|
||||
// but permitted once the path is on AllowWrite.
|
||||
func TestEvaluateAppliesWriteAllow(t *testing.T) {
|
||||
ws := resolvedTempDir(t)
|
||||
ext := tempDirOutsideDefaultTemp(t)
|
||||
ext := resolvedTempDir(t)
|
||||
build := mkdir(t, filepath.Join(ext, "build"))
|
||||
|
||||
base := Policy{Mode: ModeEnforce, EnforceWorkspace: true}
|
||||
@@ -381,7 +381,7 @@ func TestEvaluateAppliesWriteAllow(t *testing.T) {
|
||||
// backend write binds (so a sandboxed shell can write there).
|
||||
func TestWriteRootsReflectAllowWrite(t *testing.T) {
|
||||
ws := resolvedTempDir(t)
|
||||
ext := tempDirOutsideDefaultTemp(t)
|
||||
ext := resolvedTempDir(t)
|
||||
build := mkdir(t, filepath.Join(ext, "build"))
|
||||
|
||||
engine := NewEngine(EngineOptions{
|
||||
|
||||
@@ -93,21 +93,14 @@ func permissionProfileRoots(workspaceRoot string, scope *Scope) []string {
|
||||
if scope != nil {
|
||||
return scope.Roots()
|
||||
}
|
||||
var roots []string
|
||||
if root := normalizeProfilePath(workspaceRoot); root != "" {
|
||||
roots = append(roots, root)
|
||||
return []string{root}
|
||||
}
|
||||
roots = append(roots, defaultTempWriteRoots()...)
|
||||
return dedupeStrings(roots)
|
||||
return nil
|
||||
}
|
||||
|
||||
func permissionProfileReadRoots(workspaceRoot string, policy Policy, scope *Scope, writeRoots []string) []string {
|
||||
// Workspace-write follows the upstream sandbox model: full disk is readable,
|
||||
// while writes are narrowed to workspace/extra roots below. This is a
|
||||
// deliberate read-all/write-jail posture; callers that must hide secrets use
|
||||
// DenyRead to carve them out.
|
||||
readRoots := []string{profileRootPath()}
|
||||
readRoots = append(readRoots, writeRoots...)
|
||||
readRoots := append([]string{}, writeRoots...)
|
||||
if scope != nil {
|
||||
readRoots = dedupeStrings(append(readRoots, scope.ReadRoots()...))
|
||||
} else if root := normalizeProfilePath(workspaceRoot); root != "" {
|
||||
@@ -116,29 +109,7 @@ func permissionProfileReadRoots(workspaceRoot string, policy Policy, scope *Scop
|
||||
if extra := normalizeProfileDirs(policy.AllowRead); len(extra) > 0 {
|
||||
readRoots = dedupeStrings(append(readRoots, extra...))
|
||||
}
|
||||
return dedupeStrings(readRoots)
|
||||
}
|
||||
|
||||
// userGitConfigReadPaths returns the user's global git config FILES so a
|
||||
// sandboxed git can read identity and config (user.name/email, aliases) instead
|
||||
// of failing with "unable to access ~/.gitconfig". It is deliberately the config
|
||||
// files only — not the ~/.config/git directory, which can hold an XDG credential
|
||||
// store — so credentials and the rest of HOME stay unreadable. Granted at the
|
||||
// macOS-seatbelt read rule (not the cross-platform PermissionProfile) so the
|
||||
// HOME-dependent paths don't leak into the platform-agnostic policy snapshot.
|
||||
func userGitConfigReadPaths() []string {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil || strings.TrimSpace(home) == "" {
|
||||
return nil
|
||||
}
|
||||
return []string{
|
||||
filepath.Join(home, ".gitconfig"),
|
||||
filepath.Join(home, ".config", "git", "config"),
|
||||
}
|
||||
}
|
||||
|
||||
func profileRootPath() string {
|
||||
return filepath.Clean(string(filepath.Separator))
|
||||
return readRoots
|
||||
}
|
||||
|
||||
func normalizeProfileDirs(entries []string) []string {
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
import "testing"
|
||||
|
||||
func TestIsAlreadySandboxed(t *testing.T) {
|
||||
// A lone ZERO_SANDBOXED=1 (no corroborating backend marker) must NOT count as
|
||||
@@ -50,48 +47,6 @@ func TestSandboxEnvironmentMarksSandboxed(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSandboxEnvironmentPreservesCallerEnv(t *testing.T) {
|
||||
env := sandboxEnvironmentForCommand(
|
||||
[]string{
|
||||
"PATH=/custom/bin",
|
||||
"TERM=xterm-256color",
|
||||
"HOME=/home/user",
|
||||
EnvSandboxed + "=0",
|
||||
EnvSandboxBackend + "=other",
|
||||
},
|
||||
DefaultPolicy(),
|
||||
BackendMacOSSeatbelt,
|
||||
)
|
||||
|
||||
for _, want := range []string{
|
||||
"TERM=xterm-256color",
|
||||
"HOME=/home/user",
|
||||
EnvSandboxed + "=1",
|
||||
EnvSandboxBackend + "=" + string(BackendMacOSSeatbelt),
|
||||
"ZERO_SANDBOX_NETWORK=deny",
|
||||
} {
|
||||
if !stringSliceContains(env, want) {
|
||||
t.Fatalf("sandbox env = %#v, missing %q", env, want)
|
||||
}
|
||||
}
|
||||
path := envListValue(env, "PATH", "")
|
||||
if !pathListContains(path, "/custom/bin") {
|
||||
t.Fatalf("sandbox PATH = %q, want it to preserve caller path segment /custom/bin", path)
|
||||
}
|
||||
if stringSliceContains(env, EnvSandboxed+"=0") || stringSliceContains(env, EnvSandboxBackend+"=other") {
|
||||
t.Fatalf("sandbox env did not replace stale sandbox markers: %#v", env)
|
||||
}
|
||||
}
|
||||
|
||||
func pathListContains(path string, want string) bool {
|
||||
for _, entry := range strings.Split(path, ":") {
|
||||
if entry == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TestBuildCommandPlanWrapsWhenNotAlreadySandboxed(t *testing.T) {
|
||||
// Ensure neither re-entrancy marker is set so we are NOT seen as sandboxed.
|
||||
t.Setenv(EnvSandboxed, "")
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
|
||||
func TestGrantRequestPermissionsReadDoesNotGrantWrite(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
outside := tempDirOutsideDefaultTemp(t)
|
||||
outside := t.TempDir()
|
||||
target := filepath.Join(outside, "notes.txt")
|
||||
scope, err := NewScope(workspace, nil)
|
||||
if err != nil {
|
||||
@@ -97,17 +97,6 @@ func TestGrantRequestPermissionsNetworkOverlaysPolicyForTurn(t *testing.T) {
|
||||
if after := engine.Evaluate(context.Background(), request); after.Action != ActionAllow {
|
||||
t.Fatalf("network turn grant should allow shell network, got %#v", after)
|
||||
}
|
||||
plan, err := engine.BuildCommandPlan(CommandSpec{
|
||||
Name: "/bin/sh",
|
||||
Args: []string{"-c", "python3 -m http.server 8080 --bind 127.0.0.1"},
|
||||
Dir: workspace,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildCommandPlan with network grant: %v", err)
|
||||
}
|
||||
if plan.Policy.Network != NetworkAllow || plan.PermissionProfile.Network.Mode != NetworkAllow {
|
||||
t.Fatalf("network turn grant should build a network-allow command plan, got policy=%s profile=%s", plan.Policy.Network, plan.PermissionProfile.Network.Mode)
|
||||
}
|
||||
cleanup()
|
||||
if afterCleanup := engine.Evaluate(context.Background(), request); afterCleanup.Action != ActionPrompt || afterCleanup.Reason != ReasonNetworkBlocked {
|
||||
t.Fatalf("network turn grant should be cleaned up, got %#v", afterCleanup)
|
||||
@@ -116,7 +105,7 @@ func TestGrantRequestPermissionsNetworkOverlaysPolicyForTurn(t *testing.T) {
|
||||
|
||||
func TestGrantRequestPermissionsSessionPersists(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
outside := tempDirOutsideDefaultTemp(t)
|
||||
outside := t.TempDir()
|
||||
target := filepath.Join(outside, "notes.txt")
|
||||
engine := NewEngine(EngineOptions{WorkspaceRoot: workspace, Policy: DefaultPolicy()})
|
||||
|
||||
@@ -142,7 +131,7 @@ func TestGrantRequestPermissionsSessionPersists(t *testing.T) {
|
||||
|
||||
func TestPermissionProfileIncludesReadOnlyGrantAsReadRootOnly(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
outside := tempDirOutsideDefaultTemp(t)
|
||||
outside := t.TempDir()
|
||||
scope, err := NewScope(workspace, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("NewScope: %v", err)
|
||||
|
||||
Loaded 100 of 248 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user