mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-08 01:09:46 -10:00
Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
93c4d65c88 | ||
|
|
bdade37c6c | ||
|
|
729d5ee96c | ||
|
|
9994ad49bb | ||
|
|
2a0229e729 | ||
|
|
f51de6dfc6 | ||
|
|
fcc6c72c34 | ||
|
|
a4a805a6c8 | ||
|
|
9fe02ae52e | ||
|
|
9d5c82d441 | ||
|
|
97ef90d27a | ||
|
|
5c58256abc | ||
|
|
fa8734f179 | ||
|
|
5bb1bc11c3 | ||
|
|
d9b882eb0a |
No files matched your search
@@ -749,7 +749,7 @@ flowchart TD
|
||||
- **Plugins** can add tools, hooks, and skill roots. Bootstrap always registers a
|
||||
multi-root skill tool: primary Zero skills dir, optional `~/.agents/skills`,
|
||||
then plugin skill roots (earlier wins). `internal/skills` owns that merge via
|
||||
`LoadFromRoots` / `DiscoveryRoots`; single-root `Load` remains for install/write.
|
||||
`LoadFromRoots`; single-root `Load` remains for install/write.
|
||||
- **Hooks** can observe or block tool lifecycle events.
|
||||
|
||||
## End-to-End Data Flow
|
||||
|
||||
@@ -90,9 +90,6 @@ func NewAgent(conn *Conn, deps Deps) *Agent {
|
||||
return a
|
||||
}
|
||||
|
||||
// Serve runs the connection read loop until the stream closes or ctx is done.
|
||||
func (a *Agent) Serve(ctx context.Context) error { return a.conn.Serve(ctx) }
|
||||
|
||||
// ---- initialize ----
|
||||
|
||||
func (a *Agent) handleInitialize(_ context.Context, params json.RawMessage) (any, error) {
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
// Test seams: helpers only test code uses, kept out of the production binary.
|
||||
package acp
|
||||
|
||||
import "context"
|
||||
|
||||
// Serve runs the connection read loop until the stream closes or ctx is done.
|
||||
func (a *Agent) Serve(ctx context.Context) error { return a.conn.Serve(ctx) }
|
||||
|
||||
func ImageBlock(base64Data, mimeType string) ContentBlock {
|
||||
return ContentBlock{Type: "image", Data: base64Data, MimeType: mimeType}
|
||||
}
|
||||
@@ -103,10 +103,6 @@ type ContentBlock struct {
|
||||
|
||||
func TextBlock(text string) ContentBlock { return ContentBlock{Type: "text", Text: text} }
|
||||
|
||||
func ImageBlock(base64Data, mimeType string) ContentBlock {
|
||||
return ContentBlock{Type: "image", Data: base64Data, MimeType: mimeType}
|
||||
}
|
||||
|
||||
// ---- sessions ----
|
||||
|
||||
// McpServer mirrors the editor-provided MCP server entry. ZERO owns its own MCP
|
||||
@@ -233,10 +229,6 @@ func ToolContent(block ContentBlock) ToolCallContent {
|
||||
return ToolCallContent{Type: "content", Content: &block}
|
||||
}
|
||||
|
||||
func ToolDiff(path, oldText, newText string) ToolCallContent {
|
||||
return ToolCallContent{Type: "diff", Path: path, OldText: oldText, NewText: newText}
|
||||
}
|
||||
|
||||
type ToolCallLocation struct {
|
||||
Path string `json:"path"`
|
||||
Line *int `json:"line,omitempty"`
|
||||
|
||||
@@ -16,7 +16,7 @@ import (
|
||||
func TestExecuteToolCallRejectsMissingAdditionalPermissionsBeforePrompt(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewBashTool(root))
|
||||
registry.Register(tools.NewScopedBashTool(root, nil))
|
||||
|
||||
promptCalls := 0
|
||||
result, err := executeToolCall(
|
||||
@@ -51,7 +51,7 @@ func TestExecuteToolCallRejectsMissingAdditionalPermissionsBeforePrompt(t *testi
|
||||
func TestExecuteToolCallRejectsAdditionalPermissionsWithoutFlagBeforePrompt(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewBashTool(root))
|
||||
registry.Register(tools.NewScopedBashTool(root, nil))
|
||||
|
||||
promptCalls := 0
|
||||
result, err := executeToolCall(
|
||||
@@ -88,7 +88,7 @@ func TestExecuteToolCallRejectsAdditionalPermissionsWithoutFlagBeforePrompt(t *t
|
||||
func TestExecuteToolCallMissingAdditionalPermissionsErrorIsActionable(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewBashTool(root))
|
||||
registry.Register(tools.NewScopedBashTool(root, nil))
|
||||
|
||||
result, err := executeToolCall(
|
||||
context.Background(),
|
||||
@@ -117,7 +117,7 @@ func TestExecuteToolCallMissingAdditionalPermissionsErrorIsActionable(t *testing
|
||||
func TestExecuteToolCallStillPromptsForValidAdditionalPermissions(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewBashTool(root))
|
||||
registry.Register(tools.NewScopedBashTool(root, nil))
|
||||
|
||||
promptCalls := 0
|
||||
_, err := executeToolCall(
|
||||
|
||||
@@ -501,15 +501,3 @@ func hasStringPrefix(values []string, prefix []string) bool {
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func equalStringSlices(left []string, right []string) bool {
|
||||
if len(left) != len(right) {
|
||||
return false
|
||||
}
|
||||
for index := range left {
|
||||
if left[index] != right[index] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -610,15 +610,6 @@ func formatPreservedState(plan string, task *preservedTaskState, edits, tools, s
|
||||
return preservedStateLabel + "\n" + string(encoded)
|
||||
}
|
||||
|
||||
// parsePreservedState recovers the plan + skills from a prior summary's preserved
|
||||
// block. JSON escaping makes this lossless even when a skill body contains
|
||||
// markdown headings, code fences, or quotes. Returns ("", nil) when absent or
|
||||
// malformed.
|
||||
func parsePreservedState(summaryContent string) (string, []skillEntry) {
|
||||
state := parsePreservedStateBlock(summaryContent)
|
||||
return state.Plan, preservedSkillsToEntries(state.Skills)
|
||||
}
|
||||
|
||||
func parsePreservedStateBlock(summaryContent string) preservedState {
|
||||
idx := strings.LastIndex(summaryContent, preservedStateLabel)
|
||||
if idx < 0 {
|
||||
|
||||
@@ -336,7 +336,7 @@ func TestRunProactiveCompactionTriggers(t *testing.T) {
|
||||
}
|
||||
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewReadFileTool(t.TempDir()))
|
||||
registry.Register(tools.NewScopedReadFileTool(t.TempDir(), nil))
|
||||
|
||||
result, err := Run(context.Background(), strings.Repeat("y", 8000), provider, Options{
|
||||
Registry: registry,
|
||||
@@ -364,7 +364,7 @@ func TestRunNoCompactionWhenContextWindowZero(t *testing.T) {
|
||||
},
|
||||
}
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewReadFileTool(t.TempDir()))
|
||||
registry.Register(tools.NewScopedReadFileTool(t.TempDir(), nil))
|
||||
|
||||
_, err := Run(context.Background(), strings.Repeat("y", 8000), provider, Options{
|
||||
Registry: registry,
|
||||
@@ -427,7 +427,7 @@ func TestRunReactiveCompactionRecovers(t *testing.T) {
|
||||
// the compaction closure. read_file also returns a sizeable result that
|
||||
// bloats the history.
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewReadFileTool(t.TempDir()))
|
||||
registry.Register(tools.NewScopedReadFileTool(t.TempDir(), nil))
|
||||
|
||||
// ContextWindow large enough that proactive compaction never triggers, so
|
||||
// only the reactive path can save the run.
|
||||
@@ -491,7 +491,7 @@ func TestRunReactiveRetryDoesNotDoubleEmitText(t *testing.T) {
|
||||
finalText: "recovered",
|
||||
}
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewReadFileTool(t.TempDir()))
|
||||
registry.Register(tools.NewScopedReadFileTool(t.TempDir(), nil))
|
||||
|
||||
var deltas []string
|
||||
result, err := Run(context.Background(), strings.Repeat("z", 6000), provider, Options{
|
||||
|
||||
@@ -150,7 +150,7 @@ func assertNoDeferredDiscoveryMessage(t *testing.T, request zeroruntime.Completi
|
||||
func TestPartitionToolsInactiveIsByteIdenticalAndDropsToolSearch(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewReadFileTool(root))
|
||||
registry.Register(tools.NewScopedReadFileTool(root, nil))
|
||||
registry.Register(fakeDeferredTool{name: "mcp__srv__a", desc: "tool a"})
|
||||
registry.Register(fakeToolSearchTool{})
|
||||
|
||||
@@ -305,8 +305,8 @@ func TestPartitionToolsActiveExcludesDisabledDeferredFromDiscoveryAndExposed(t *
|
||||
func TestPartitionToolsActiveHidesUnloadedExposesLoaded(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewReadFileTool(root)) // non-deferred builtin
|
||||
registry.Register(fakeToolSearchTool{}) // non-deferred, must stay exposed
|
||||
registry.Register(tools.NewScopedReadFileTool(root, nil)) // non-deferred builtin
|
||||
registry.Register(fakeToolSearchTool{}) // non-deferred, must stay exposed
|
||||
registry.Register(fakeDeferredTool{name: "mcp__srv__alpha", desc: "alpha tool"})
|
||||
registry.Register(fakeDeferredTool{name: "mcp__srv__beta", desc: "beta tool"})
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
func TestExecuteToolCallCategorizesFilteredDenial(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewReadFileTool(root))
|
||||
registry.Register(tools.NewScopedReadFileTool(root, nil))
|
||||
|
||||
result, _ := executeToolCall(context.Background(), registry, ToolCall{ID: "c1", Name: "read_file", Arguments: `{"path":"x"}`}, PermissionModeAuto, Options{
|
||||
DisabledTools: []string{"read_file"},
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
// Test seams: helpers only test code uses, kept out of the production binary.
|
||||
package agent
|
||||
|
||||
import (
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
func equalStringSlices(left []string, right []string) bool {
|
||||
if len(left) != len(right) {
|
||||
return false
|
||||
}
|
||||
for index := range left {
|
||||
if left[index] != right[index] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// parsePreservedState recovers the plan + skills from a prior summary's preserved
|
||||
// block. JSON escaping makes this lossless even when a skill body contains
|
||||
// markdown headings, code fences, or quotes. Returns ("", nil) when absent or
|
||||
// malformed.
|
||||
func parsePreservedState(summaryContent string) (string, []skillEntry) {
|
||||
state := parsePreservedStateBlock(summaryContent)
|
||||
return state.Plan, preservedSkillsToEntries(state.Skills)
|
||||
}
|
||||
|
||||
// partitionTools builds the per-turn advertised tool list and optional
|
||||
// tool_search discovery text. 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
|
||||
// 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.
|
||||
func partitionTools(registry *tools.Registry, permissionMode PermissionMode, options Options, loaded map[string]bool) ([]zeroruntime.ToolDefinition, string) {
|
||||
return partitionToolsCached(registry, permissionMode, options, loaded, nil)
|
||||
}
|
||||
@@ -138,7 +138,7 @@ func TestRunResetsEmptyTurnCounterOnToolCall(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeAgentTestFile(t, root+"/notes.txt", "alpha")
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewReadFileTool(root))
|
||||
registry.Register(tools.NewScopedReadFileTool(root, nil))
|
||||
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
@@ -260,7 +260,7 @@ func TestRunInjectsPlanNotCalledReminderForMultiStepTask(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeAgentTestFile(t, root+"/notes.txt", "alpha")
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewReadFileTool(root))
|
||||
registry.Register(tools.NewScopedReadFileTool(root, nil))
|
||||
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
@@ -291,7 +291,7 @@ func TestRunDoesNotInjectPlanReminderForTrivialTask(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeAgentTestFile(t, root+"/notes.txt", "alpha")
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewReadFileTool(root))
|
||||
registry.Register(tools.NewScopedReadFileTool(root, nil))
|
||||
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
@@ -319,7 +319,7 @@ func TestRunDoesNotInjectNotCalledReminderWhenPlanUsed(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeAgentTestFile(t, root+"/notes.txt", "alpha")
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewReadFileTool(root))
|
||||
registry.Register(tools.NewScopedReadFileTool(root, nil))
|
||||
registry.Register(tools.NewUpdatePlanTool())
|
||||
|
||||
provider := &mockProvider{
|
||||
@@ -349,7 +349,7 @@ func TestRunInjectsStalePlanReminderAfterManyToolCalls(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeAgentTestFile(t, root+"/notes.txt", "alpha")
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewReadFileTool(root))
|
||||
registry.Register(tools.NewScopedReadFileTool(root, nil))
|
||||
registry.Register(tools.NewUpdatePlanTool())
|
||||
|
||||
// Turn 1 calls update_plan (so the not-called reminder never triggers), then
|
||||
@@ -383,7 +383,7 @@ func TestRunStalePlanReminderIsOneShotPerInterval(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeAgentTestFile(t, root+"/notes.txt", "alpha")
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewReadFileTool(root))
|
||||
registry.Register(tools.NewScopedReadFileTool(root, nil))
|
||||
registry.Register(tools.NewUpdatePlanTool())
|
||||
|
||||
turns := [][]zeroruntime.StreamEvent{
|
||||
@@ -418,7 +418,7 @@ func TestRunInjectsToolOnlyProgressReminder(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeAgentTestFile(t, root+"/notes.txt", "alpha")
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewReadFileTool(root))
|
||||
registry.Register(tools.NewScopedReadFileTool(root, nil))
|
||||
|
||||
turns := make([][]zeroruntime.StreamEvent, 0, toolOnlyProgressReminderAt+1)
|
||||
for i := 0; i < toolOnlyProgressReminderAt; i++ {
|
||||
|
||||
@@ -267,10 +267,6 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
})
|
||||
}
|
||||
|
||||
// Build the per-turn tool list first so proactive compaction can include
|
||||
// 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.
|
||||
// Build the per-turn tool list first so proactive compaction can include
|
||||
// the tool-definition tokens (they ride on every request) in its estimate.
|
||||
// partitionTools depends only on registry/permissions/options/loaded, not on
|
||||
@@ -2866,19 +2862,6 @@ 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
|
||||
// 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
|
||||
// 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.
|
||||
func partitionTools(registry *tools.Registry, permissionMode PermissionMode, options Options, loaded map[string]bool) ([]zeroruntime.ToolDefinition, string) {
|
||||
return partitionToolsCached(registry, permissionMode, options, loaded, nil)
|
||||
}
|
||||
|
||||
// partitionToolsCached is partitionTools with an optional per-tool definition
|
||||
// cache. The partitioning itself (visibility, deferral, ordering) is recomputed
|
||||
// every call — it must be, because a tool's deferred state can flip mid-run (e.g.
|
||||
|
||||
+59
-53
@@ -863,9 +863,6 @@ func TestRunReportsTruncationFinishReason(t *testing.T) {
|
||||
if result.FinishReason != zeroruntime.FinishReasonLength {
|
||||
t.Fatalf("FinishReason = %q, want %q", result.FinishReason, zeroruntime.FinishReasonLength)
|
||||
}
|
||||
if !result.Truncated() {
|
||||
t.Fatal("Truncated() = false, want true for a length-capped response")
|
||||
}
|
||||
if result.TruncationNotice() == "" {
|
||||
t.Fatal("TruncationNotice() empty for a truncated response")
|
||||
}
|
||||
@@ -883,7 +880,7 @@ func TestRunNormalCompletionIsNotTruncated(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Truncated() || result.TruncationNotice() != "" {
|
||||
if result.TruncationNotice() != "" {
|
||||
t.Fatalf("normal completion reported as truncated: reason=%q", result.FinishReason)
|
||||
}
|
||||
}
|
||||
@@ -960,7 +957,7 @@ func TestRunEmitsUsageEvents(t *testing.T) {
|
||||
func TestRunAdvertisesRuntimeToolDefinitions(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewReadFileTool(root))
|
||||
registry.Register(tools.NewScopedReadFileTool(root, nil))
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{{
|
||||
{Type: zeroruntime.StreamEventText, Content: "done"},
|
||||
@@ -1167,9 +1164,9 @@ func TestRunRequestsPermissionBeforeWebSearchExecution(t *testing.T) {
|
||||
func TestRunFiltersAdvertisedTools(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewReadFileTool(root))
|
||||
registry.Register(tools.NewGrepTool(root))
|
||||
registry.Register(tools.NewWriteFileTool(root))
|
||||
registry.Register(tools.NewScopedReadFileTool(root, nil))
|
||||
registry.Register(tools.NewScopedGrepTool(root, nil))
|
||||
registry.Register(tools.NewScopedWriteFileTool(root, nil))
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{{
|
||||
{Type: zeroruntime.StreamEventText, Content: "done"},
|
||||
@@ -1197,7 +1194,7 @@ func TestRunFiltersAdvertisedTools(t *testing.T) {
|
||||
func TestRunRejectsFilteredToolCalls(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewReadFileTool(root))
|
||||
registry.Register(tools.NewScopedReadFileTool(root, nil))
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
@@ -1234,7 +1231,7 @@ func TestRunRejectsFilteredToolCalls(t *testing.T) {
|
||||
func TestRunRejectsToolCallsOutsideEnabledList(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewReadFileTool(root))
|
||||
registry.Register(tools.NewScopedReadFileTool(root, nil))
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
@@ -1272,7 +1269,7 @@ func TestRunExecutesToolCallThroughRegistry(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeAgentTestFile(t, filepath.Join(root, "notes.txt"), "alpha\nbeta\n")
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewReadFileTool(root))
|
||||
registry.Register(tools.NewScopedReadFileTool(root, nil))
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
@@ -1356,7 +1353,7 @@ func TestRunPreservesRequestPrefixAcrossTurns(t *testing.T) {
|
||||
func TestRunSanitizesMalformedToolCallArgumentsBeforeRetry(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewReadFileTool(root))
|
||||
registry.Register(tools.NewScopedReadFileTool(root, nil))
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
@@ -1424,7 +1421,7 @@ func TestRunRecoversFirstObjectFromConcatenatedToolArgs(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewReadFileTool(root))
|
||||
registry.Register(tools.NewScopedReadFileTool(root, nil))
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
@@ -1471,8 +1468,8 @@ func TestRunDefersSelfCorrectFeedbackUntilAfterToolBatch(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewWriteFileTool(root))
|
||||
registry.Register(tools.NewReadFileTool(root))
|
||||
registry.Register(tools.NewScopedWriteFileTool(root, nil))
|
||||
registry.Register(tools.NewScopedReadFileTool(root, nil))
|
||||
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
@@ -1547,7 +1544,7 @@ func TestRunBatchesSelfCorrectOncePerTurn(t *testing.T) {
|
||||
// after a later call in the same turn supersedes it.
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewWriteFileTool(root))
|
||||
registry.Register(tools.NewScopedWriteFileTool(root, nil))
|
||||
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
@@ -1604,7 +1601,7 @@ func TestRunBatchesSelfCorrectOncePerTurn(t *testing.T) {
|
||||
func TestRunDeniesPromptToolWithoutUnsafePermission(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewWriteFileTool(root))
|
||||
registry.Register(tools.NewScopedWriteFileTool(root, nil))
|
||||
provider := providerCallingWriteFileThenAnswer("write denied")
|
||||
var permissionEvents []PermissionEvent
|
||||
|
||||
@@ -1647,7 +1644,7 @@ func TestRunDeniesPromptToolWithoutUnsafePermission(t *testing.T) {
|
||||
func TestRunRequestsPromptToolPermissionBeforeExecution(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewWriteFileTool(root))
|
||||
registry.Register(tools.NewScopedWriteFileTool(root, nil))
|
||||
provider := providerCallingWriteFileThenAnswer("write approved")
|
||||
var requests []PermissionRequest
|
||||
var permissionEvents []PermissionEvent
|
||||
@@ -1703,7 +1700,7 @@ func TestRunRequestsPromptToolPermissionBeforeExecution(t *testing.T) {
|
||||
func TestRunAllowsWorkspaceWriteWithoutPromptWhenSandboxPolicyPermits(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewWriteFileTool(root))
|
||||
registry.Register(tools.NewScopedWriteFileTool(root, nil))
|
||||
provider := providerCallingWriteFileThenAnswer("write done")
|
||||
var permissionEvents []PermissionEvent
|
||||
|
||||
@@ -1752,7 +1749,7 @@ func TestRunAllowsWorkspaceWriteWithoutPromptWhenSandboxPolicyPermits(t *testing
|
||||
func TestRunDeniesPromptToolWhenPermissionRequestDenied(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewWriteFileTool(root))
|
||||
registry.Register(tools.NewScopedWriteFileTool(root, nil))
|
||||
provider := providerCallingWriteFileThenAnswer("write denied")
|
||||
var requests []PermissionRequest
|
||||
var permissionEvents []PermissionEvent
|
||||
@@ -1800,7 +1797,7 @@ func TestRunDeniesPromptToolWhenPermissionRequestDenied(t *testing.T) {
|
||||
func TestRunAbortsWhenPermissionRequestCanceled(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewWriteFileTool(root))
|
||||
registry.Register(tools.NewScopedWriteFileTool(root, nil))
|
||||
provider := providerCallingWriteFileThenAnswer("write should not continue")
|
||||
var permissionEvents []PermissionEvent
|
||||
|
||||
@@ -1896,7 +1893,7 @@ func TestRunPersistsAlwaysAllowPermissionDecision(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewWriteFileTool(root))
|
||||
registry.Register(tools.NewScopedWriteFileTool(root, nil))
|
||||
provider := providerCallingWriteFileThenAnswer("write approved")
|
||||
var permissionEvents []PermissionEvent
|
||||
policy := sandbox.DefaultPolicy()
|
||||
@@ -1968,7 +1965,7 @@ func TestRunSessionAllowSkipsMatchingPromptWithoutPersistentGrant(t *testing.T)
|
||||
t.Fatal(err)
|
||||
}
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewWriteFileTool(root))
|
||||
registry.Register(tools.NewScopedWriteFileTool(root, nil))
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
@@ -2049,7 +2046,7 @@ func TestRunSessionAllowSkipsMatchingPromptWithoutPersistentGrant(t *testing.T)
|
||||
func TestRunCommandPrefixApprovalSkipsLaterMatchingBashPrompt(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewBashTool(root))
|
||||
registry.Register(tools.NewScopedBashTool(root, nil))
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
@@ -2251,7 +2248,7 @@ func TestRunPersistentCommandPrefixApprovalSkipsFutureSessionPrompt(t *testing.T
|
||||
t.Fatal(err)
|
||||
}
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewBashTool(root))
|
||||
registry.Register(tools.NewScopedBashTool(root, nil))
|
||||
policy := sandbox.DefaultPolicy()
|
||||
policy.Network = sandbox.NetworkAllow
|
||||
|
||||
@@ -2358,7 +2355,7 @@ func TestRunPersistentCommandPrefixStillPromptsForNetwork(t *testing.T) {
|
||||
t.Fatalf("seed command prefix: %v", err)
|
||||
}
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewBashTool(root))
|
||||
registry.Register(tools.NewScopedBashTool(root, nil))
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
@@ -2407,7 +2404,11 @@ func TestRunApprovedNetworkBashPromptAppliesTurnNetworkGrant(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
command := "PATH=.:$PATH curl https://example.com"
|
||||
if runtime.GOOS == "windows" {
|
||||
command = "set PATH=.;%PATH% && curl https://example.com"
|
||||
if windowsTestUsesPowerShell() {
|
||||
command = `$env:PATH = '.;' + $env:PATH; curl.cmd https://example.com`
|
||||
} else {
|
||||
command = "set PATH=.;%PATH% && curl https://example.com"
|
||||
}
|
||||
fakeCurl := filepath.Join(root, "curl.cmd")
|
||||
if err := os.WriteFile(fakeCurl, []byte("@echo fake curl %*\r\n"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -2419,7 +2420,7 @@ func TestRunApprovedNetworkBashPromptAppliesTurnNetworkGrant(t *testing.T) {
|
||||
}
|
||||
}
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewBashTool(root))
|
||||
registry.Register(tools.NewScopedBashTool(root, nil))
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
@@ -2486,7 +2487,7 @@ func TestRunApprovedNetworkBashPromptAppliesTurnNetworkGrant(t *testing.T) {
|
||||
func TestRunDoesNotOfferPrefixApprovalForUnsafeBashCommand(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewBashTool(root))
|
||||
registry.Register(tools.NewScopedBashTool(root, nil))
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
@@ -2528,13 +2529,17 @@ func TestRunDoesNotOfferPrefixApprovalForUnsafeBashCommand(t *testing.T) {
|
||||
|
||||
func TestRunPromptsForDestructiveShellInsteadOfSandboxDeny(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
command := "echo rm -rf /"
|
||||
if runtime.GOOS == "windows" && windowsTestUsesPowerShell() {
|
||||
command = `Write-Output 'rm -rf /'`
|
||||
}
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewBashTool(root))
|
||||
registry.Register(tools.NewScopedBashTool(root, nil))
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "bash"},
|
||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{"command":"echo rm -rf /"}`},
|
||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{"command":` + quoteJSONString(command) + `}`},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
@@ -2598,7 +2603,7 @@ func TestRunAlwaysAllowWithoutSandboxStillAllowsCall(t *testing.T) {
|
||||
// prior code denied it because persistPermissionGrant errors when Sandbox==nil.
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewWriteFileTool(root))
|
||||
registry.Register(tools.NewScopedWriteFileTool(root, nil))
|
||||
provider := providerCallingWriteFileThenAnswer("write approved")
|
||||
var permissionEvents []PermissionEvent
|
||||
|
||||
@@ -2630,6 +2635,12 @@ func TestRunAlwaysAllowWithoutSandboxStillAllowsCall(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func windowsTestUsesPowerShell() bool {
|
||||
executable, _ := tools.HostShellCommand("")
|
||||
name := strings.ToLower(filepath.Base(executable))
|
||||
return strings.Contains(name, "powershell") || strings.HasPrefix(name, "pwsh")
|
||||
}
|
||||
|
||||
func containsPermissionDecision(decisions []PermissionDecisionAction, want PermissionDecisionAction) bool {
|
||||
for _, decision := range decisions {
|
||||
if decision == want {
|
||||
@@ -2662,7 +2673,7 @@ func TestRunCancellationPreservesContextCanceledIdentity(t *testing.T) {
|
||||
func TestRunGrantsPromptToolInUnsafeMode(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewWriteFileTool(root))
|
||||
registry.Register(tools.NewScopedWriteFileTool(root, nil))
|
||||
provider := providerCallingWriteFileThenAnswer("write done")
|
||||
var permissionEvents []PermissionEvent
|
||||
|
||||
@@ -2714,7 +2725,7 @@ func TestRunEmitsPermissionEventForPersistentSandboxGrant(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewWriteFileTool(root))
|
||||
registry.Register(tools.NewScopedWriteFileTool(root, nil))
|
||||
provider := providerCallingWriteFileThenAnswer("write done")
|
||||
var permissionEvents []PermissionEvent
|
||||
|
||||
@@ -2893,7 +2904,7 @@ func TestRunAppliesSandboxEvenInUnsafeMode(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
outside := filepath.Join(tempDirOutsideDefaultTemp(t), "escape.txt")
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewWriteFileTool(root))
|
||||
registry.Register(tools.NewScopedWriteFileTool(root, nil))
|
||||
provider := providerCallingWritePathThenAnswer(outside, "sandbox handled")
|
||||
var permissionEvents []PermissionEvent
|
||||
|
||||
@@ -2942,7 +2953,7 @@ func TestRunStopsAfterMaxTurns(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeAgentTestFile(t, filepath.Join(root, "notes.txt"), "alpha")
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewReadFileTool(root))
|
||||
registry.Register(tools.NewScopedReadFileTool(root, nil))
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{{
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "read_file"},
|
||||
@@ -2972,7 +2983,7 @@ func TestRunRequestsFinalAnswerAfterMaxTurns(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeAgentTestFile(t, filepath.Join(root, "notes.txt"), "alpha")
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewReadFileTool(root))
|
||||
registry.Register(tools.NewScopedReadFileTool(root, nil))
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
@@ -3163,22 +3174,17 @@ func TestBuildSystemPromptInjectsHostShellContext(t *testing.T) {
|
||||
t.Fatalf("expected operating system in environment block, got %q", prompt)
|
||||
}
|
||||
if runtime.GOOS == "windows" {
|
||||
for _, want := range []string{"Windows cmd.exe syntax", "cwd argument", "MSYS binaries", "grep", "require_escalated", "use double quotes around the value"} {
|
||||
if !strings.Contains(prompt, want) {
|
||||
t.Fatalf("expected Windows shell guidance to mention %q, got %q", want, prompt)
|
||||
}
|
||||
wants := []string{"workdir/cwd", "MSYS binaries"}
|
||||
if windowsTestUsesPowerShell() {
|
||||
wants = append(wants, "PowerShell", "Get-ChildItem", "Select-String", "$env:NAME", "require_escalated")
|
||||
} else {
|
||||
wants = append(wants, "cmd.exe", "double quotes")
|
||||
}
|
||||
// The examples must themselves use the safe (double-quoted) form; a
|
||||
// single-quoted example here would teach the model the exact syntax
|
||||
// that fails under cmd.exe.
|
||||
for _, want := range []string{`--jq ".a | b"`, `-run "A|B"`} {
|
||||
for _, want := range wants {
|
||||
if !strings.Contains(prompt, want) {
|
||||
t.Fatalf("expected double-quoted example %q in Windows shell guidance, got %q", want, prompt)
|
||||
t.Fatalf("expected selected Windows shell guidance to mention %q, got %q", want, prompt)
|
||||
}
|
||||
}
|
||||
if strings.Contains(prompt, `'.a | b'`) || strings.Contains(prompt, `'A|B'`) {
|
||||
t.Fatalf("Windows shell guidance must not show the unsafe single-quoted form, got %q", prompt)
|
||||
}
|
||||
} else if !strings.Contains(prompt, "/bin/sh syntax") {
|
||||
t.Fatalf("expected POSIX shell guidance in prompt, got %q", prompt)
|
||||
}
|
||||
@@ -3231,7 +3237,7 @@ func TestBuildSystemPromptAllowsSpecModeOverride(t *testing.T) {
|
||||
func TestSpecDraftAdvertisesOnlySafeDraftTools(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
for _, tool := range tools.CoreTools(root) {
|
||||
for _, tool := range tools.CoreToolsScoped(root, nil) {
|
||||
registry.Register(tool)
|
||||
}
|
||||
specmode.RegisterDraftTools(registry, root, nil)
|
||||
@@ -3268,7 +3274,7 @@ func TestSpecDraftAdvertisesOnlySafeDraftTools(t *testing.T) {
|
||||
func TestSpecDraftDeniesHiddenToolCalls(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewWriteFileTool(root))
|
||||
registry.Register(tools.NewScopedWriteFileTool(root, nil))
|
||||
provider := providerCallingWriteFileThenAnswer("done")
|
||||
|
||||
result, err := Run(context.Background(), "draft", provider, Options{
|
||||
@@ -3301,7 +3307,7 @@ func TestSpecDraftDeniesHiddenToolCalls(t *testing.T) {
|
||||
func TestSpecDraftDeniesBashToolCalls(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewBashTool(root))
|
||||
registry.Register(tools.NewScopedBashTool(root, nil))
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
@@ -3442,7 +3448,7 @@ func TestRunSurfacesDroppedToolCallAlongsideValidCall(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewReadFileTool(root))
|
||||
registry.Register(tools.NewScopedReadFileTool(root, nil))
|
||||
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
|
||||
@@ -14,7 +14,7 @@ func TestPartitionToolsActiveAppendsLoadedToolAfterEagerBlock(t *testing.T) {
|
||||
// "read_file".
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewReadFileTool(root)) // non-deferred, "read_file"
|
||||
registry.Register(tools.NewScopedReadFileTool(root, nil)) // non-deferred, "read_file"
|
||||
registry.Register(fakeToolSearchTool{})
|
||||
registry.Register(fakeDeferredTool{name: "mcp__srv__alpha", desc: "alpha"})
|
||||
registry.Register(fakeDeferredTool{name: "mcp__srv__beta", desc: "beta"})
|
||||
|
||||
@@ -180,7 +180,7 @@ func TestInlineAdditionalPermissionsRequiresPromptUnderAutoAllow(t *testing.T) {
|
||||
},
|
||||
}
|
||||
decision := &sandbox.Decision{Action: sandbox.ActionAllow, AutoAllowed: true}
|
||||
if !shouldRequestPermission(tools.NewBashTool(t.TempDir()), args, false, decision) {
|
||||
if !shouldRequestPermission(tools.NewScopedBashTool(t.TempDir(), nil), args, false, decision) {
|
||||
t.Fatal("inline additional permissions must request approval even when a sandboxed shell would otherwise auto-allow")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/repomap"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/workspaceseed"
|
||||
)
|
||||
|
||||
@@ -310,11 +311,7 @@ func workspaceContext(cwd string) string {
|
||||
b.WriteString("<environment>\n")
|
||||
b.WriteString("Working directory: " + cwd + "\n")
|
||||
b.WriteString("Operating system: " + runtime.GOOS + "\n")
|
||||
if runtime.GOOS == "windows" {
|
||||
b.WriteString("Shell syntax: Windows cmd.exe syntax for exec_command/bash tools. To put | & > < etc inside an arg value, use double quotes around the value, not single quotes (single quotes do not protect metachars in cmd.exe): gh --jq \".a | b\", go test -run \"A|B\". Do not pipe to or invoke POSIX coreutils from Git for Windows (usr\\bin head/grep/tail/cat/...): they are MSYS binaries and fail under the write-restricted sandbox; use native Zero tools (grep, read_file, list_directory, glob) or cmd.exe findstr/more instead, or sandbox_permissions require_escalated only when host-level execution is truly required. Prefer the workdir/cwd argument over cd when changing directories.\n")
|
||||
} else {
|
||||
b.WriteString("Shell syntax: /bin/sh syntax for exec_command/bash tools; prefer the workdir/cwd argument instead of cd when changing directories.\n")
|
||||
}
|
||||
b.WriteString(tools.HostShellEnvironmentGuidance() + "\n")
|
||||
if branch := gitBranchForPrompt(cwd); branch != "" {
|
||||
b.WriteString("Git branch: " + branch + "\n")
|
||||
}
|
||||
|
||||
@@ -427,13 +427,6 @@ type Result struct {
|
||||
IncompleteReason string
|
||||
}
|
||||
|
||||
// Truncated reports whether the final response ended abnormally (cut off at the
|
||||
// output token cap or withheld by a content filter) rather than completing
|
||||
// naturally. Callers can use it to warn the user that FinalAnswer is incomplete.
|
||||
func (result Result) Truncated() bool {
|
||||
return result.FinishReason != ""
|
||||
}
|
||||
|
||||
// TruncationNotice returns a user-facing warning when the final response was
|
||||
// truncated, or "" for a normal completion. Shared by the CLI and TUI so the
|
||||
// wording stays consistent.
|
||||
|
||||
@@ -31,10 +31,6 @@ type AgentRunner interface {
|
||||
|
||||
type AgentRunnerFunc func(context.Context, AgentRunInput) AgentRunResult
|
||||
|
||||
func (fn AgentRunnerFunc) Run(ctx context.Context, input AgentRunInput) AgentRunResult {
|
||||
return fn(ctx, input)
|
||||
}
|
||||
|
||||
// defaultAgentOutputLimit caps captured stdout/stderr per stream so a chatty or
|
||||
// runaway agent cannot exhaust memory or bloat the benchmark report.
|
||||
const defaultAgentOutputLimit = 1 << 20 // 1 MiB per stream
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
// Test seams: helpers only test code uses, kept out of the production binary.
|
||||
package agenteval
|
||||
|
||||
import "context"
|
||||
|
||||
func (fn AgentRunnerFunc) Run(ctx context.Context, input AgentRunInput) AgentRunResult {
|
||||
return fn(ctx, input)
|
||||
}
|
||||
@@ -59,7 +59,7 @@ var completionRoot = completionNode{
|
||||
{names: []string{"delete", "rm"}}, {names: []string{"edit"}}, {names: []string{"path"}},
|
||||
}},
|
||||
{names: []string{"plugins", "plugin"}, children: []completionNode{
|
||||
{names: []string{"list"}}, {names: []string{"add"}}, {names: []string{"remove", "rm"}},
|
||||
{names: []string{"list"}}, {names: []string{"add"}}, {names: []string{"info"}}, {names: []string{"remove", "rm"}},
|
||||
}},
|
||||
{names: []string{"backends", "backend"}, children: leafNodes("doctor")},
|
||||
{names: []string{"skills", "skill"}, children: []completionNode{
|
||||
|
||||
@@ -159,6 +159,8 @@ func TestCompletionTreeCoversAliasesNestingAndCommonFlags(t *testing.T) {
|
||||
assertCandidates(t, byPath["mcp oauth"], "login", "logout", "status")
|
||||
assertCandidates(t, byPath["sandbox grants"], "list", "allow", "deny", "revoke", "clear")
|
||||
assertCandidates(t, byPath["completions"], "bash", "zsh", "fish", "powershell", "elvish")
|
||||
assertCandidates(t, byPath["plugins"], "list", "add", "info", "remove", "rm")
|
||||
assertCandidates(t, byPath["plugin"], "list", "add", "info", "remove", "rm")
|
||||
}
|
||||
|
||||
func assertCandidates(t *testing.T, got []string, wants ...string) {
|
||||
|
||||
@@ -42,16 +42,20 @@ var errProviderCommandTimeout = errors.New("provider command timeout")
|
||||
|
||||
func runProviderCommand(command string, timeout time.Duration) ([]byte, []byte, error) {
|
||||
cmd := shellCommand(command)
|
||||
configureCommandProcess(cmd)
|
||||
// Bound the time Wait spends draining I/O pipes after the process exits,
|
||||
// in case an orphaned descendant still holds the write ends open.
|
||||
cmd.WaitDelay = time.Second
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
proc, err := startCommandProcess(cmd)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
defer proc.Close()
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
@@ -63,9 +67,21 @@ func runProviderCommand(command string, timeout time.Duration) ([]byte, []byte,
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
// The shell exited (whether via ErrWaitDelay because a
|
||||
// background descendant kept the inherited stdout/stderr pipes
|
||||
// open, or via a nonzero exit status) but a leftover descendant
|
||||
// may still be running. Terminate is a no-op against an
|
||||
// already-dead tree, so always call it rather than gating on
|
||||
// the specific error to avoid leaking that descendant.
|
||||
proc.Terminate()
|
||||
}
|
||||
if errors.Is(err, exec.ErrWaitDelay) {
|
||||
return stdout.Bytes(), stderr.Bytes(), errProviderCommandTimeout
|
||||
}
|
||||
return stdout.Bytes(), stderr.Bytes(), err
|
||||
case <-timer.C:
|
||||
terminateCommandProcess(cmd)
|
||||
proc.Terminate()
|
||||
<-done
|
||||
return stdout.Bytes(), stderr.Bytes(), errProviderCommandTimeout
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -67,7 +69,8 @@ func TestLoadProviderCommandFailureIncludesExitAndRedactsOutput(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestLoadProviderCommandTimeout(t *testing.T) {
|
||||
command := writeCommand(t, commandScript{SleepSeconds: 10})
|
||||
pidFile := filepath.Join(t.TempDir(), "sleep.pid")
|
||||
command := writeCommand(t, commandScript{SleepSeconds: 10, PidFile: pidFile})
|
||||
|
||||
start := time.Now()
|
||||
_, err := LoadProviderCommand(command)
|
||||
@@ -85,6 +88,102 @@ func TestLoadProviderCommandTimeout(t *testing.T) {
|
||||
if elapsed > maxElapsed {
|
||||
t.Fatalf("timeout returned after %s, want roughly 5s", elapsed)
|
||||
}
|
||||
assertProcessTerminatedIfStarted(t, pidFile)
|
||||
}
|
||||
|
||||
// TestLoadProviderCommandTerminatesBackgroundChild covers a command that
|
||||
// exits immediately but leaves a detached child holding the inherited
|
||||
// stdout/stderr pipes open (e.g. `sleep 600 & exit`). cmd.Wait() only
|
||||
// unblocks once WaitDelay elapses, and that path must still terminate the
|
||||
// leftover child instead of just returning and leaking it.
|
||||
func TestLoadProviderCommandTerminatesBackgroundChild(t *testing.T) {
|
||||
pidFile := filepath.Join(t.TempDir(), "bg.pid")
|
||||
childLifetime := 10 * time.Second
|
||||
command := writeCommand(t, commandScript{
|
||||
Stdout: `{"name":"cmd","provider":"openai","apiKey":"sk-command","model":"gpt-command"}`,
|
||||
BackgroundSleepSeconds: int(childLifetime / time.Second),
|
||||
BackgroundPidFile: pidFile,
|
||||
})
|
||||
|
||||
start := time.Now()
|
||||
_, err := LoadProviderCommand(command)
|
||||
elapsed := time.Since(start)
|
||||
if err == nil {
|
||||
t.Fatal("LoadProviderCommand() error = nil, want error from WaitDelay-bounded wait")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "timed out after 5s") {
|
||||
t.Fatalf("error = %q, want timeout", err.Error())
|
||||
}
|
||||
if elapsed >= childLifetime {
|
||||
t.Fatalf("returned after %s, want before the background child finishes naturally after %s", elapsed, childLifetime)
|
||||
}
|
||||
assertProcessTerminated(t, pidFile)
|
||||
}
|
||||
|
||||
// TestLoadProviderCommandTerminatesBackgroundChildOnFailure covers the same
|
||||
// leaked-descendant scenario as TestLoadProviderCommandTerminatesBackgroundChild,
|
||||
// but with a shell that exits nonzero. Go's exec.Cmd.Wait only returns
|
||||
// exec.ErrWaitDelay when the command itself exited successfully; a nonzero
|
||||
// exit yields the bare *ExitError instead, so the leftover child must still
|
||||
// be terminated on that path.
|
||||
func TestLoadProviderCommandTerminatesBackgroundChildOnFailure(t *testing.T) {
|
||||
pidFile := filepath.Join(t.TempDir(), "bg-fail.pid")
|
||||
childLifetime := 10 * time.Second
|
||||
command := writeCommand(t, commandScript{
|
||||
Stderr: "boom",
|
||||
ExitCode: 7,
|
||||
BackgroundSleepSeconds: int(childLifetime / time.Second),
|
||||
BackgroundPidFile: pidFile,
|
||||
})
|
||||
|
||||
start := time.Now()
|
||||
_, err := LoadProviderCommand(command)
|
||||
elapsed := time.Since(start)
|
||||
if err == nil {
|
||||
t.Fatal("LoadProviderCommand() error = nil, want failure from nonzero exit")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "exit status") {
|
||||
t.Fatalf("error = %q, want exit status failure", err.Error())
|
||||
}
|
||||
if elapsed >= childLifetime {
|
||||
t.Fatalf("returned after %s, want before the background child finishes naturally after %s", elapsed, childLifetime)
|
||||
}
|
||||
assertProcessTerminated(t, pidFile)
|
||||
}
|
||||
|
||||
func assertProcessTerminatedIfStarted(t *testing.T, pidFile string) {
|
||||
t.Helper()
|
||||
|
||||
// The basic timeout fixture is intentionally unsynchronized. On a loaded
|
||||
// Windows runner, the cold PowerShell child can be terminated before it
|
||||
// records its PID; the synchronized background-child tests below retain
|
||||
// the strict PID and liveness checks.
|
||||
if _, err := os.Stat(pidFile); errors.Is(err, os.ErrNotExist) {
|
||||
return
|
||||
}
|
||||
assertProcessTerminated(t, pidFile)
|
||||
}
|
||||
|
||||
func assertProcessTerminated(t *testing.T, pidFile string) {
|
||||
t.Helper()
|
||||
|
||||
data, err := os.ReadFile(pidFile)
|
||||
if err != nil {
|
||||
t.Fatalf("read sleeper pid file: %v", err)
|
||||
}
|
||||
pid, err := strconv.Atoi(strings.TrimSpace(string(data)))
|
||||
if err != nil {
|
||||
t.Fatalf("parse sleeper pid %q: %v", data, err)
|
||||
}
|
||||
|
||||
deadline := time.Now().Add(3 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if !processAlive(pid) {
|
||||
return
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("sleeper process %d still alive after timeout", pid)
|
||||
}
|
||||
|
||||
func TestLoadProviderCommandInvalidJSON(t *testing.T) {
|
||||
@@ -118,6 +217,14 @@ type commandScript struct {
|
||||
Stderr string
|
||||
ExitCode int
|
||||
SleepSeconds int
|
||||
PidFile string
|
||||
|
||||
// BackgroundSleepSeconds, if set, spawns a detached child that keeps the
|
||||
// inherited stdout/stderr handles open well after the script itself
|
||||
// exits, simulating a `sleep 600 & exit` style command. BackgroundPidFile
|
||||
// records the detached child's PID.
|
||||
BackgroundSleepSeconds int
|
||||
BackgroundPidFile string
|
||||
}
|
||||
|
||||
func writeCommand(t *testing.T, script commandScript) string {
|
||||
@@ -128,7 +235,11 @@ func writeCommand(t *testing.T, script commandScript) string {
|
||||
path := filepath.Join(dir, "provider.cmd")
|
||||
lines := []string{"@echo off"}
|
||||
if script.SleepSeconds > 0 {
|
||||
lines = append(lines, "powershell -NoProfile -Command \"Start-Sleep -Seconds "+itoa(script.SleepSeconds)+"\"")
|
||||
sleep := "Start-Sleep -Seconds " + itoa(script.SleepSeconds)
|
||||
if script.PidFile != "" {
|
||||
sleep = "Set-Content -Path '" + psSingleQuote(script.PidFile) + "' -Value $PID -Encoding Ascii; " + sleep
|
||||
}
|
||||
lines = append(lines, "powershell -NoProfile -Command \""+sleep+"\"")
|
||||
}
|
||||
if script.Stdout != "" {
|
||||
lines = append(lines, "echo "+script.Stdout)
|
||||
@@ -136,6 +247,15 @@ func writeCommand(t *testing.T, script commandScript) string {
|
||||
if script.Stderr != "" {
|
||||
lines = append(lines, "echo "+script.Stderr+" 1>&2")
|
||||
}
|
||||
if script.BackgroundSleepSeconds > 0 {
|
||||
readyFile := script.BackgroundPidFile + ".ready"
|
||||
bgSleep := "Set-Content -LiteralPath '" + psSingleQuote(script.BackgroundPidFile) + "' -Value $PID -Encoding Ascii; " +
|
||||
"Set-Content -LiteralPath '" + psSingleQuote(readyFile) + "' -Value ready -Encoding Ascii; " +
|
||||
"Start-Sleep -Seconds " + itoa(script.BackgroundSleepSeconds)
|
||||
lines = append(lines, "start /B powershell -NoProfile -Command \""+bgSleep+"\"")
|
||||
waitForReady := "while (-not (Test-Path -LiteralPath '" + psSingleQuote(readyFile) + "')) { Start-Sleep -Milliseconds 10 }"
|
||||
lines = append(lines, "powershell -NoProfile -Command \""+waitForReady+"\"")
|
||||
}
|
||||
lines = append(lines, "exit /b "+itoa(script.ExitCode))
|
||||
if err := os.WriteFile(path, []byte(strings.Join(lines, "\r\n")), 0o700); err != nil {
|
||||
t.Fatalf("write command: %v", err)
|
||||
@@ -146,6 +266,9 @@ func writeCommand(t *testing.T, script commandScript) string {
|
||||
path := filepath.Join(dir, "provider.sh")
|
||||
lines := []string{"#!/bin/sh"}
|
||||
if script.SleepSeconds > 0 {
|
||||
if script.PidFile != "" {
|
||||
lines = append(lines, "echo $$ > '"+shSingleQuote(script.PidFile)+"'")
|
||||
}
|
||||
lines = append(lines, "sleep "+itoa(script.SleepSeconds))
|
||||
}
|
||||
if script.Stdout != "" {
|
||||
@@ -154,6 +277,10 @@ func writeCommand(t *testing.T, script commandScript) string {
|
||||
if script.Stderr != "" {
|
||||
lines = append(lines, "printf '%s\\n' '"+script.Stderr+"' >&2")
|
||||
}
|
||||
if script.BackgroundSleepSeconds > 0 {
|
||||
lines = append(lines, "sleep "+itoa(script.BackgroundSleepSeconds)+" &")
|
||||
lines = append(lines, "echo $! > '"+shSingleQuote(script.BackgroundPidFile)+"'")
|
||||
}
|
||||
lines = append(lines, "exit "+itoa(script.ExitCode))
|
||||
if err := os.WriteFile(path, []byte(strings.Join(lines, "\n")), 0o700); err != nil {
|
||||
t.Fatalf("write command: %v", err)
|
||||
@@ -161,6 +288,19 @@ func writeCommand(t *testing.T, script commandScript) string {
|
||||
return path
|
||||
}
|
||||
|
||||
// psSingleQuote escapes a value for interpolation inside a PowerShell
|
||||
// single-quoted string literal, where a literal quote is doubled.
|
||||
func psSingleQuote(value string) string {
|
||||
return strings.ReplaceAll(value, "'", "''")
|
||||
}
|
||||
|
||||
// shSingleQuote escapes a value for interpolation inside a POSIX shell
|
||||
// single-quoted string literal, where a literal quote must close the
|
||||
// quoted section, emit an escaped quote, then reopen it.
|
||||
func shSingleQuote(value string) string {
|
||||
return strings.ReplaceAll(value, "'", `'\''`)
|
||||
}
|
||||
|
||||
func itoa(value int) string {
|
||||
if value == 0 {
|
||||
return "0"
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
// Test seams: helpers only test code uses, kept out of the production binary.
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ToolsOverride builds a ToolsConfig that explicitly overrides the deferred-tool
|
||||
// threshold (including to 0, which disables deferral). Use this for programmatic
|
||||
// Overrides — a bare ToolsConfig{DeferThreshold: 0} is indistinguishable from
|
||||
// "unset" and will not override.
|
||||
func ToolsOverride(deferThreshold int) ToolsConfig {
|
||||
return ToolsConfig{DeferThreshold: deferThreshold, deferThresholdSet: true}
|
||||
}
|
||||
|
||||
// ValidateFile reads and parses path as a Zero FileConfig and runs the same
|
||||
// semantic provider/model rules used during resolution. It returns the parsed
|
||||
// config (zero value on parse failure) plus any structured issues. A parse
|
||||
// failure yields a single issue whose Message is the underlying JSON error's
|
||||
// text (already flattened via Error(), not chained — callers cannot recover
|
||||
// the original *json.SyntaxError / *json.UnmarshalTypeError via errors.As).
|
||||
func ValidateFile(path string) (FileConfig, []Issue) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return FileConfig{}, []Issue{{Message: fmt.Sprintf("read config %s: %v", path, err)}}
|
||||
}
|
||||
|
||||
var cfg FileConfig
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
return FileConfig{}, []Issue{{Message: fmt.Sprintf("invalid config JSON %s: %v", path, err)}}
|
||||
}
|
||||
|
||||
issues := validateSemantics(cfg)
|
||||
issues = append(issues, unknownFieldIssues(data)...)
|
||||
return cfg, issues
|
||||
}
|
||||
|
||||
// SetProviderDescription sets a provider's description VERBATIM — including to
|
||||
// empty. The generic UpsertProvider merge treats empty fields as "leave
|
||||
// unchanged", so clearing a description needs this dedicated setter.
|
||||
func SetProviderDescription(path string, name string, description string) (FileConfig, error) {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
return FileConfig{}, fmt.Errorf("config path is required")
|
||||
}
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return FileConfig{}, fmt.Errorf("provider name is required")
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return FileConfig{}, fmt.Errorf("read config %s: %w", path, err)
|
||||
}
|
||||
cfg := FileConfig{}
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
return FileConfig{}, fmt.Errorf("invalid config JSON %s: %w", path, err)
|
||||
}
|
||||
for index := range cfg.Providers {
|
||||
if strings.EqualFold(strings.TrimSpace(cfg.Providers[index].Name), name) {
|
||||
cfg.Providers[index].Description = strings.TrimSpace(description)
|
||||
if err := writeConfigFile(path, cfg); err != nil {
|
||||
return FileConfig{}, err
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
}
|
||||
return FileConfig{}, fmt.Errorf("provider %q not found", name)
|
||||
}
|
||||
@@ -3,18 +3,59 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os/exec"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
func configureCommandProcess(cmd *exec.Cmd) {
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||
type commandProcess struct {
|
||||
groupID int
|
||||
anchor *exec.Cmd
|
||||
anchorInput io.WriteCloser
|
||||
}
|
||||
|
||||
func terminateCommandProcess(cmd *exec.Cmd) {
|
||||
if cmd.Process == nil {
|
||||
return
|
||||
// startCommandProcess retains a live process-group leader until cleanup. The
|
||||
// provider shell may be reaped before Wait finishes draining descendant-held
|
||||
// pipes, so its PID cannot safely identify the group after Wait returns.
|
||||
func startCommandProcess(cmd *exec.Cmd) (*commandProcess, error) {
|
||||
anchor := exec.Command("sh", "-c", "read _")
|
||||
anchor.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||
anchorInput, err := anchor.StdinPipe()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
|
||||
_ = cmd.Process.Kill()
|
||||
if err := anchor.Start(); err != nil {
|
||||
_ = anchorInput.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
proc := &commandProcess{groupID: anchor.Process.Pid, anchor: anchor, anchorInput: anchorInput}
|
||||
if cmd.SysProcAttr == nil {
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{}
|
||||
}
|
||||
cmd.SysProcAttr.Setpgid = true
|
||||
cmd.SysProcAttr.Pgid = proc.groupID
|
||||
if err := cmd.Start(); err != nil {
|
||||
proc.Close()
|
||||
return nil, err
|
||||
}
|
||||
return proc, nil
|
||||
}
|
||||
|
||||
func (p *commandProcess) Terminate() {
|
||||
if p.groupID != 0 {
|
||||
_ = syscall.Kill(-p.groupID, syscall.SIGKILL)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *commandProcess) Close() {
|
||||
if p.anchorInput != nil {
|
||||
_ = p.anchorInput.Close()
|
||||
p.anchorInput = nil
|
||||
}
|
||||
if p.anchor != nil {
|
||||
_ = p.anchor.Wait()
|
||||
p.anchor = nil
|
||||
}
|
||||
p.groupID = 0
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
//go:build !windows
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"syscall"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCommandProcessPreservesSysProcAttr(t *testing.T) {
|
||||
cmd := exec.Command("sh", "-c", "exit 0")
|
||||
attr := &syscall.SysProcAttr{}
|
||||
cmd.SysProcAttr = attr
|
||||
|
||||
proc, err := startCommandProcess(cmd)
|
||||
if err != nil {
|
||||
t.Fatalf("startCommandProcess: %v", err)
|
||||
}
|
||||
defer proc.Close()
|
||||
|
||||
if cmd.SysProcAttr != attr {
|
||||
t.Fatal("startCommandProcess replaced SysProcAttr")
|
||||
}
|
||||
if !cmd.SysProcAttr.Setpgid || cmd.SysProcAttr.Pgid != proc.groupID {
|
||||
t.Fatalf("process group = Setpgid %v, Pgid %d; want true, %d", cmd.SysProcAttr.Setpgid, cmd.SysProcAttr.Pgid, proc.groupID)
|
||||
}
|
||||
if err := cmd.Wait(); err != nil {
|
||||
t.Fatalf("Wait: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommandProcessRetainsGroupIdentityAfterProviderWait(t *testing.T) {
|
||||
cmd := exec.Command("sh", "-c", "exit 7")
|
||||
proc, err := startCommandProcess(cmd)
|
||||
if err != nil {
|
||||
t.Fatalf("startCommandProcess: %v", err)
|
||||
}
|
||||
defer func() {
|
||||
proc.Terminate()
|
||||
proc.Close()
|
||||
}()
|
||||
|
||||
providerPID := cmd.Process.Pid
|
||||
anchorPID := proc.groupID
|
||||
if err := cmd.Wait(); err == nil {
|
||||
t.Fatal("Wait error = nil, want nonzero exit")
|
||||
}
|
||||
if anchorPID == providerPID {
|
||||
t.Fatalf("group identity reused provider PID %d", providerPID)
|
||||
}
|
||||
if !processAlive(anchorPID) {
|
||||
t.Fatalf("group leader %d exited before cleanup", anchorPID)
|
||||
}
|
||||
|
||||
proc.Terminate()
|
||||
proc.Close()
|
||||
if processAlive(anchorPID) {
|
||||
t.Fatalf("group leader %d still alive after cleanup", anchorPID)
|
||||
}
|
||||
}
|
||||
|
||||
func processAlive(pid int) bool {
|
||||
err := syscall.Kill(pid, 0)
|
||||
return err == nil || err == syscall.EPERM
|
||||
}
|
||||
@@ -3,23 +3,165 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"syscall"
|
||||
"time"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
func configureCommandProcess(cmd *exec.Cmd) {}
|
||||
// configureCommandProcess starts the process suspended so it can be
|
||||
// assigned to the job object before its main thread (and therefore any
|
||||
// code it runs) executes. Without this, a fast command can spawn and
|
||||
// detach a descendant before AssignProcessToJobObject runs, letting that
|
||||
// descendant escape the job and survive termination.
|
||||
func configureCommandProcess(cmd *exec.Cmd) {
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{CreationFlags: windows.CREATE_SUSPENDED}
|
||||
}
|
||||
|
||||
func terminateCommandProcess(cmd *exec.Cmd) {
|
||||
func startCommandProcess(cmd *exec.Cmd) (*commandProcess, error) {
|
||||
configureCommandProcess(cmd)
|
||||
if err := cmd.Start(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return attachCommandProcess(cmd), nil
|
||||
}
|
||||
|
||||
// commandProcess tracks a started provider command so its entire process
|
||||
// tree can be terminated on timeout. It prefers a job object: taskkill /T
|
||||
// walks the tree by parent PID in user space and can miss descendants,
|
||||
// letting them run to completion while Wait blocks on inherited pipes.
|
||||
type commandProcess struct {
|
||||
cmd *exec.Cmd
|
||||
job windows.Handle
|
||||
processHandle windows.Handle
|
||||
}
|
||||
|
||||
func attachCommandProcess(cmd *exec.Cmd) *commandProcess {
|
||||
proc := &commandProcess{cmd: cmd}
|
||||
if cmd.Process == nil {
|
||||
return proc
|
||||
}
|
||||
// The main thread is suspended (see configureCommandProcess); resume it
|
||||
// once we're done attaching, however that turns out. If resuming fails
|
||||
// outright (Toolhelp/OpenThread/ResumeThread errors are otherwise
|
||||
// swallowed), the process would sit suspended forever and never exit on
|
||||
// its own, making the failure look like an ordinary 5s provider-command
|
||||
// timeout. Terminate it immediately instead so the real cause surfaces
|
||||
// quickly and no suspended process is left behind.
|
||||
defer func() {
|
||||
if !resumeMainThread(cmd.Process.Pid) {
|
||||
proc.Terminate()
|
||||
}
|
||||
}()
|
||||
|
||||
handle, err := windows.OpenProcess(windows.PROCESS_SET_QUOTA|windows.PROCESS_TERMINATE|windows.PROCESS_QUERY_LIMITED_INFORMATION, false, uint32(cmd.Process.Pid))
|
||||
if err != nil {
|
||||
return proc
|
||||
}
|
||||
proc.processHandle = handle
|
||||
|
||||
job, err := windows.CreateJobObject(nil, nil)
|
||||
if err != nil {
|
||||
return proc
|
||||
}
|
||||
if err := windows.AssignProcessToJobObject(job, handle); err != nil {
|
||||
_ = windows.CloseHandle(job)
|
||||
return proc
|
||||
}
|
||||
proc.job = job
|
||||
return proc
|
||||
}
|
||||
|
||||
// resumeMainThread resumes every (assumed suspended) thread of pid and
|
||||
// reports whether at least one was actually resumed. Callers must treat a
|
||||
// false result as a failed resume, not a benign no-op: a suspended process
|
||||
// that's never woken up will neither exit nor produce output on its own.
|
||||
func resumeMainThread(pid int) bool {
|
||||
snapshot, err := windows.CreateToolhelp32Snapshot(windows.TH32CS_SNAPTHREAD, 0)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer func() { _ = windows.CloseHandle(snapshot) }()
|
||||
|
||||
var entry windows.ThreadEntry32
|
||||
entry.Size = uint32(unsafe.Sizeof(entry))
|
||||
resumed := false
|
||||
for err := windows.Thread32First(snapshot, &entry); err == nil; err = windows.Thread32Next(snapshot, &entry) {
|
||||
if entry.OwnerProcessID != uint32(pid) {
|
||||
continue
|
||||
}
|
||||
thread, err := windows.OpenThread(windows.THREAD_SUSPEND_RESUME, false, entry.ThreadID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if _, err := windows.ResumeThread(thread); err == nil {
|
||||
resumed = true
|
||||
}
|
||||
_ = windows.CloseHandle(thread)
|
||||
}
|
||||
return resumed
|
||||
}
|
||||
|
||||
func (p *commandProcess) Terminate() {
|
||||
if p.job != 0 {
|
||||
// A job handle means the process tree was contained from launch, so
|
||||
// TerminateJobObject alone kills every member atomically. Don't also
|
||||
// fall through to the taskkill/PID path below: by the time
|
||||
// Terminate runs on the ErrWaitDelay path, cmd.Wait has already
|
||||
// reaped the root process, so its PID may have been reused by an
|
||||
// unrelated process, and forcing /T /PID against a reused PID would
|
||||
// kill the wrong tree.
|
||||
_ = windows.TerminateJobObject(p.job, 1)
|
||||
return
|
||||
}
|
||||
taskkill := taskkillPath()
|
||||
_ = exec.Command(taskkill, "/T", "/F", "/PID", strconv.Itoa(cmd.Process.Pid)).Run()
|
||||
_ = cmd.Process.Kill()
|
||||
if p.cmd.Process == nil {
|
||||
return
|
||||
}
|
||||
if p.processHandle == 0 {
|
||||
// Without a retained identity, never target a numeric PID that may
|
||||
// have been reaped and reused. os.Process.Kill uses Go's original
|
||||
// process state instead.
|
||||
_ = p.cmd.Process.Kill()
|
||||
return
|
||||
}
|
||||
// Fallback for the rare case where job creation or assignment failed:
|
||||
// only invoke taskkill while the identity-preserving handle confirms the
|
||||
// original root is still active. Keeping that handle open through Run
|
||||
// prevents Windows from reusing its PID for an unrelated process.
|
||||
var exitCode uint32
|
||||
if err := windows.GetExitCodeProcess(p.processHandle, &exitCode); err == nil && exitCode == stillActive {
|
||||
taskkill := taskkillPath()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
_ = exec.CommandContext(ctx, taskkill, "/T", "/F", "/PID", strconv.Itoa(p.cmd.Process.Pid)).Run()
|
||||
cancel()
|
||||
}
|
||||
_ = windows.TerminateProcess(p.processHandle, 1)
|
||||
}
|
||||
|
||||
// Close releases the retained handles without touching any still-running
|
||||
// descendants: the job carries no KILL_ON_JOB_CLOSE limit, so on the success
|
||||
// path a provider command's detached helpers keep running exactly as they did
|
||||
// before job objects were introduced. Descendant termination happens
|
||||
// explicitly via Terminate, called only on timeout/error.
|
||||
func (p *commandProcess) Close() {
|
||||
if p.job != 0 {
|
||||
_ = windows.CloseHandle(p.job)
|
||||
p.job = 0
|
||||
}
|
||||
if p.processHandle != 0 {
|
||||
_ = windows.CloseHandle(p.processHandle)
|
||||
p.processHandle = 0
|
||||
}
|
||||
}
|
||||
|
||||
const stillActive = uint32(259) // STILL_ACTIVE
|
||||
|
||||
func taskkillPath() string {
|
||||
systemRoot := os.Getenv("SystemRoot")
|
||||
if systemRoot == "" {
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
//go:build windows
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// TestResumeMainThreadReportsFailureForUnknownPID covers the contract
|
||||
// attachCommandProcess relies on: when no thread belonging to pid can be
|
||||
// found (here, because the pid doesn't correspond to a live process),
|
||||
// resumeMainThread must report false rather than silently doing nothing,
|
||||
// so a caller can react (e.g. terminate the process) instead of leaving it
|
||||
// suspended indefinitely.
|
||||
func TestResumeMainThreadReportsFailureForUnknownPID(t *testing.T) {
|
||||
if resumeMainThread(0) {
|
||||
t.Fatal("resumeMainThread(0) = true, want false for a pid with no threads")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommandProcessRetainsIdentityAfterWait(t *testing.T) {
|
||||
cmd := exec.Command("cmd", "/C", "exit /b 0")
|
||||
configureCommandProcess(cmd)
|
||||
if err := cmd.Start(); err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
proc := attachCommandProcess(cmd)
|
||||
t.Cleanup(proc.Close)
|
||||
if proc.processHandle == 0 {
|
||||
t.Fatal("attachCommandProcess did not retain a process handle")
|
||||
}
|
||||
if err := cmd.Wait(); err != nil {
|
||||
t.Fatalf("Wait: %v", err)
|
||||
}
|
||||
|
||||
var code uint32
|
||||
if err := windows.GetExitCodeProcess(proc.processHandle, &code); err != nil {
|
||||
t.Fatalf("GetExitCodeProcess after Wait: %v", err)
|
||||
}
|
||||
if code == stillActive {
|
||||
t.Fatalf("exit code = STILL_ACTIVE after Wait")
|
||||
}
|
||||
|
||||
proc.Close()
|
||||
if proc.job != 0 || proc.processHandle != 0 {
|
||||
t.Fatalf("Close left handles open: job=%v process=%v", proc.job, proc.processHandle)
|
||||
}
|
||||
}
|
||||
|
||||
func processAlive(pid int) bool {
|
||||
handle, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION, false, uint32(pid))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer func() { _ = windows.CloseHandle(handle) }()
|
||||
|
||||
var code uint32
|
||||
if err := windows.GetExitCodeProcess(handle, &code); err != nil {
|
||||
return false
|
||||
}
|
||||
return code == stillActive
|
||||
}
|
||||
@@ -322,14 +322,6 @@ type SwarmConfig struct {
|
||||
MaxTeamSize int `json:"maxTeamSize,omitempty"`
|
||||
}
|
||||
|
||||
// ToolsOverride builds a ToolsConfig that explicitly overrides the deferred-tool
|
||||
// threshold (including to 0, which disables deferral). Use this for programmatic
|
||||
// Overrides — a bare ToolsConfig{DeferThreshold: 0} is indistinguishable from
|
||||
// "unset" and will not override.
|
||||
func ToolsOverride(deferThreshold int) ToolsConfig {
|
||||
return ToolsConfig{DeferThreshold: deferThreshold, deferThresholdSet: true}
|
||||
}
|
||||
|
||||
func (cfg *ToolsConfig) UnmarshalJSON(data []byte) error {
|
||||
type rawTools struct {
|
||||
DeferThreshold *int `json:"deferThreshold"`
|
||||
|
||||
@@ -3,7 +3,6 @@ package config
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
// Issue is a single structured problem found while validating a config file.
|
||||
@@ -13,28 +12,6 @@ type Issue struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// ValidateFile reads and parses path as a Zero FileConfig and runs the same
|
||||
// semantic provider/model rules used during resolution. It returns the parsed
|
||||
// config (zero value on parse failure) plus any structured issues. A parse
|
||||
// failure yields a single issue whose Message wraps the underlying JSON error
|
||||
// so callers can extract *json.SyntaxError / *json.UnmarshalTypeError offsets
|
||||
// via errors.As.
|
||||
func ValidateFile(path string) (FileConfig, []Issue) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return FileConfig{}, []Issue{{Message: fmt.Sprintf("read config %s: %v", path, err)}}
|
||||
}
|
||||
|
||||
var cfg FileConfig
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
return FileConfig{}, []Issue{{Message: fmt.Errorf("invalid config JSON %s: %w", path, err).Error()}}
|
||||
}
|
||||
|
||||
issues := validateSemantics(cfg)
|
||||
issues = append(issues, unknownFieldIssues(data)...)
|
||||
return cfg, issues
|
||||
}
|
||||
|
||||
// ValidateBytes parses data as a Zero FileConfig and runs the same semantic
|
||||
// provider/model rules as ValidateFile. It returns the parsed config (zero
|
||||
// value on parse failure) plus any structured issues. A parse failure yields a
|
||||
|
||||
@@ -431,39 +431,6 @@ func EditProvider(path string, edit ProviderEdit) (FileConfig, error) {
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// SetProviderDescription sets a provider's description VERBATIM — including to
|
||||
// empty. The generic UpsertProvider merge treats empty fields as "leave
|
||||
// unchanged", so clearing a description needs this dedicated setter.
|
||||
func SetProviderDescription(path string, name string, description string) (FileConfig, error) {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
return FileConfig{}, fmt.Errorf("config path is required")
|
||||
}
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return FileConfig{}, fmt.Errorf("provider name is required")
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return FileConfig{}, fmt.Errorf("read config %s: %w", path, err)
|
||||
}
|
||||
cfg := FileConfig{}
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
return FileConfig{}, fmt.Errorf("invalid config JSON %s: %w", path, err)
|
||||
}
|
||||
for index := range cfg.Providers {
|
||||
if strings.EqualFold(strings.TrimSpace(cfg.Providers[index].Name), name) {
|
||||
cfg.Providers[index].Description = strings.TrimSpace(description)
|
||||
if err := writeConfigFile(path, cfg); err != nil {
|
||||
return FileConfig{}, err
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
}
|
||||
return FileConfig{}, fmt.Errorf("provider %q not found", name)
|
||||
}
|
||||
|
||||
// migrateStoredProviderKey moves a credential-store entry to a new provider
|
||||
// name: write-new-then-delete-old, so an interruption can leave a duplicate but
|
||||
// never a missing key. A missing source entry is a no-op (the marker may be
|
||||
|
||||
@@ -16,7 +16,7 @@ func TestBuildCountsProjectGuidelinesAndFreeBudget(t *testing.T) {
|
||||
writeTestFile(t, root, "AGENTS.md", strings.Repeat("project rules\n", 200))
|
||||
|
||||
registry := tools.NewRegistry()
|
||||
for _, tool := range tools.CoreTools(root) {
|
||||
for _, tool := range tools.CoreToolsScoped(root, nil) {
|
||||
registry.Register(tool)
|
||||
}
|
||||
|
||||
@@ -80,7 +80,7 @@ func TestBuildHasStableJSONContractAndCategoryMath(t *testing.T) {
|
||||
writeTestFile(t, root, "ZERO.md", strings.Repeat("zero rules\n", 16))
|
||||
|
||||
registry := tools.NewRegistry()
|
||||
for _, tool := range tools.CoreTools(root) {
|
||||
for _, tool := range tools.CoreToolsScoped(root, nil) {
|
||||
registry.Register(tool)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
// Test seams: helpers only test code uses, kept out of the production binary.
|
||||
package cron
|
||||
|
||||
func (s Schedule) String() string { return s.expr }
|
||||
@@ -23,8 +23,6 @@ type Schedule struct {
|
||||
expr string
|
||||
}
|
||||
|
||||
func (s Schedule) String() string { return s.expr }
|
||||
|
||||
// full returns a bitset with every value in [min,max] set.
|
||||
func full(min, max int) uint64 {
|
||||
var m uint64
|
||||
|
||||
+42
-7
@@ -99,35 +99,60 @@ func (s *Store) Add(job Job) (Job, error) {
|
||||
job.Status = StatusActive
|
||||
}
|
||||
job.CreatedAt = s.now().UTC()
|
||||
id, err := s.allocID()
|
||||
id, unlock, err := s.reserveID()
|
||||
if err != nil {
|
||||
return Job{}, err
|
||||
}
|
||||
// Held from reservation through initial persistence: without this, Remove
|
||||
// (which takes the same per-ID lock before deleting) could delete the
|
||||
// bare reservation directory the instant after Mkdir and report success,
|
||||
// then writeJob's MkdirAll would silently resurrect it and persist the
|
||||
// job anyway — or a second Add could re-reserve the freed id while this
|
||||
// writer is still running, putting two writers on the same metadata path.
|
||||
defer unlock()
|
||||
job.ID = id
|
||||
if err := s.writeJob(job); err != nil {
|
||||
if err := s.writeReservedJob(job); err != nil {
|
||||
return Job{}, err
|
||||
}
|
||||
return job, nil
|
||||
}
|
||||
|
||||
func (s *Store) allocID() (string, error) {
|
||||
// reserveID atomically reserves an unused job id (by os.Mkdir-ing its
|
||||
// directory) and returns the per-ID lock held for that id, still acquired.
|
||||
// The caller must release it (via the returned unlock) only once the
|
||||
// reservation has been committed (metadata.json written) or torn down —
|
||||
// see Add.
|
||||
func (s *Store) reserveID() (string, func(), error) {
|
||||
if err := os.MkdirAll(s.root, 0o700); err != nil {
|
||||
return "", nil, fmt.Errorf("create cron store root: %w", err)
|
||||
}
|
||||
base := s.now().UTC().Format("20060102-150405")
|
||||
for n := 0; n < 100; n++ {
|
||||
id := base
|
||||
if n > 0 {
|
||||
id = fmt.Sprintf("%s-%d", base, n)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(s.root, id)); errors.Is(err, os.ErrNotExist) {
|
||||
return id, nil
|
||||
unlock, err := s.lockJob(id)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
mkErr := os.Mkdir(s.jobDir(id), 0o700)
|
||||
if mkErr == nil {
|
||||
return id, unlock, nil
|
||||
}
|
||||
unlock()
|
||||
if errors.Is(mkErr, os.ErrExist) {
|
||||
continue
|
||||
}
|
||||
return "", nil, fmt.Errorf("reserve cron job id %q: %w", id, mkErr)
|
||||
}
|
||||
return "", errors.New("could not allocate a unique cron job id")
|
||||
return "", nil, errors.New("could not allocate a unique cron job id")
|
||||
}
|
||||
|
||||
func (s *Store) jobDir(id string) string { return filepath.Join(s.root, id) }
|
||||
|
||||
// validID rejects ids that could escape the store root (path separators or
|
||||
// traversal). allocID-generated timestamp ids always pass; this guards
|
||||
// traversal). reserveID-generated timestamp ids always pass; this guards
|
||||
// externally-supplied ids (get/update/remove/append).
|
||||
func validID(id string) bool {
|
||||
return id != "" && id != "." && id != ".." && !strings.ContainsAny(id, `/\`) && filepath.Base(id) == id
|
||||
@@ -153,6 +178,16 @@ func (s *Store) writeJob(job Job) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) writeReservedJob(job Job) error {
|
||||
if err := s.writeJob(job); err != nil {
|
||||
if cleanupErr := os.RemoveAll(s.jobDir(job.ID)); cleanupErr != nil {
|
||||
return errors.Join(err, fmt.Errorf("remove failed cron job reservation: %w", cleanupErr))
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) Get(id string) (Job, error) {
|
||||
if !validID(id) {
|
||||
return Job{}, fmt.Errorf("invalid cron job id %q", id)
|
||||
|
||||
@@ -2,9 +2,11 @@ package cron
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@@ -74,6 +76,160 @@ func TestStoreAddListGetRemove(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreAddReservesUniqueIDsAcrossConcurrentStores(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
now := func() time.Time { return time.Unix(1000, 0).UTC() }
|
||||
const adds = 32
|
||||
|
||||
start := make(chan struct{})
|
||||
results := make(chan Job, adds)
|
||||
errs := make(chan error, adds)
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < adds; i++ {
|
||||
wg.Add(1)
|
||||
go func(index int) {
|
||||
defer wg.Done()
|
||||
store := NewStore(StoreOptions{RootDir: root, Now: now})
|
||||
<-start
|
||||
job, err := store.Add(Job{Expr: "* * * * *", Prompt: fmt.Sprintf("job-%d", index)})
|
||||
if err != nil {
|
||||
errs <- err
|
||||
return
|
||||
}
|
||||
results <- job
|
||||
}(i)
|
||||
}
|
||||
close(start)
|
||||
wg.Wait()
|
||||
close(results)
|
||||
close(errs)
|
||||
|
||||
for err := range errs {
|
||||
t.Errorf("concurrent Add: %v", err)
|
||||
}
|
||||
seen := make(map[string]struct{}, adds)
|
||||
reader := NewStore(StoreOptions{RootDir: root, Now: now})
|
||||
for job := range results {
|
||||
if _, duplicate := seen[job.ID]; duplicate {
|
||||
t.Errorf("concurrent Add returned duplicate ID %q", job.ID)
|
||||
}
|
||||
seen[job.ID] = struct{}{}
|
||||
persisted, err := reader.Get(job.ID)
|
||||
if err != nil {
|
||||
t.Errorf("Get(%q): %v", job.ID, err)
|
||||
continue
|
||||
}
|
||||
if persisted.Prompt != job.Prompt {
|
||||
t.Errorf("Get(%q).Prompt = %q, want %q", job.ID, persisted.Prompt, job.Prompt)
|
||||
}
|
||||
}
|
||||
if len(seen) != adds {
|
||||
t.Fatalf("unique jobs = %d, want %d", len(seen), adds)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAddDoesNotLeakReservationToConcurrentRemove reproduces the audit
|
||||
// finding that Remove could observe and delete an in-flight Add's bare
|
||||
// reservation directory (Mkdir'd but not yet holding metadata.json) and
|
||||
// report success — after which Add's writeJob would silently resurrect the
|
||||
// directory and persist the job anyway. Add now holds the same per-ID lock
|
||||
// Remove takes, from reservation through initial persistence, so a
|
||||
// concurrent Remove for that id must wait until the reservation is either
|
||||
// committed or torn down, never observing the bare reservation in between.
|
||||
func TestAddDoesNotLeakReservationToConcurrentRemove(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
|
||||
// Simulate the in-flight window Add now holds the lock across: the id is
|
||||
// reserved (its directory exists) but metadata.json has not been written.
|
||||
id, unlock, err := store.reserveID()
|
||||
if err != nil {
|
||||
t.Fatalf("reserveID: %v", err)
|
||||
}
|
||||
if _, statErr := os.Stat(store.jobDir(id)); statErr != nil {
|
||||
t.Fatalf("reservation directory missing: %v", statErr)
|
||||
}
|
||||
|
||||
removeErr := make(chan error, 1)
|
||||
go func() {
|
||||
removeErr <- store.Remove(id)
|
||||
}()
|
||||
|
||||
// Remove cannot proceed past the shared per-ID lock we're holding, so it's
|
||||
// safe to commit the job here, before releasing it: if Remove could race
|
||||
// ahead of this write, it would delete the bare reservation instead of
|
||||
// waiting to observe (and remove) the committed job.
|
||||
job := Job{ID: id, Expr: "* * * * *", Prompt: "job", Status: StatusActive}
|
||||
if err := store.writeJob(job); err != nil {
|
||||
t.Fatalf("writeJob: %v", err)
|
||||
}
|
||||
unlock()
|
||||
|
||||
if err := <-removeErr; err != nil {
|
||||
t.Fatalf("Remove of the committed job: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(store.jobDir(id)); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("job directory should be gone after Remove: %v", err)
|
||||
}
|
||||
if _, err := store.Get(id); !errors.Is(err, ErrJobNotFound) {
|
||||
t.Fatalf("Get after Remove = %v, want ErrJobNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReserveIDSerializesOnPerIDLock covers the other half of the audit
|
||||
// finding: a second Add attempting the SAME candidate id while the first
|
||||
// reservation is still held must block on the shared per-ID lock (not just
|
||||
// race the directory Mkdir), so it never ends up writing metadata.json for
|
||||
// an id whose first writer is still in flight. It moves on to the next
|
||||
// candidate id instead.
|
||||
func TestReserveIDSerializesOnPerIDLock(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
|
||||
id, unlock, err := store.reserveID()
|
||||
if err != nil {
|
||||
t.Fatalf("first reserveID: %v", err)
|
||||
}
|
||||
|
||||
type reservation struct {
|
||||
id string
|
||||
err error
|
||||
}
|
||||
second := make(chan reservation, 1)
|
||||
go func() {
|
||||
id2, unlock2, err := store.reserveID()
|
||||
if err == nil {
|
||||
unlock2()
|
||||
}
|
||||
second <- reservation{id: id2, err: err}
|
||||
}()
|
||||
|
||||
// The second reserveID cannot observe an id until this lock is released:
|
||||
// it contends on the same per-ID lock file before ever attempting Mkdir.
|
||||
unlock()
|
||||
|
||||
got := <-second
|
||||
if got.err != nil {
|
||||
t.Fatalf("second reserveID: %v", got.err)
|
||||
}
|
||||
if got.id == id {
|
||||
t.Fatalf("second reserveID returned the first id %q while its reservation was still held", got.id)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteReservedJobRemovesReservationOnFailure(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
job := Job{ID: "reserved", Expr: "* * * * *", Prompt: "job"}
|
||||
if err := os.MkdirAll(filepath.Join(store.jobDir(job.ID), "metadata.json.tmp"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := store.writeReservedJob(job); err == nil {
|
||||
t.Fatal("writeReservedJob error = nil, want metadata write failure")
|
||||
}
|
||||
if _, err := os.Stat(store.jobDir(job.ID)); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("failed reservation still exists: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreAppendRun(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
job, _ := s.Add(Job{Expr: "* * * * *", Prompt: "x", Status: StatusActive})
|
||||
|
||||
@@ -37,16 +37,6 @@ const (
|
||||
StreamProviderOpenAI = "openai"
|
||||
)
|
||||
|
||||
// BatchProviders lists valid stt.provider values, for config validation.
|
||||
func BatchProviders() []string {
|
||||
return []string{ProviderLocal, ProviderGroq, ProviderOpenAI}
|
||||
}
|
||||
|
||||
// StreamProviders lists valid stt.streamProvider values, for config validation.
|
||||
func StreamProviders() []string {
|
||||
return []string{StreamProviderLocal, StreamProviderDeepgram, StreamProviderOpenAI}
|
||||
}
|
||||
|
||||
// AudioFormat identifies a recorded container format, sniffed from the bytes —
|
||||
// desktop recorders produce WAV, Termux's recorder produces M4A/AAC.
|
||||
type AudioFormat string
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
// Test seams: helpers only test code uses, kept out of the production binary.
|
||||
package lsp
|
||||
|
||||
import "os/exec"
|
||||
|
||||
// Available reports whether a configured server for the path exists on PATH.
|
||||
func Available(path string) bool {
|
||||
cmd, ok := ServerFor(path)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
_, err := exec.LookPath(cmd[0])
|
||||
return err == nil
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
package lsp
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
@@ -176,16 +175,6 @@ func LanguageID(path string) (string, bool) {
|
||||
return id, ok
|
||||
}
|
||||
|
||||
// Available reports whether a configured server for the path exists on PATH.
|
||||
func Available(path string) bool {
|
||||
cmd, ok := ServerFor(path)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
_, err := exec.LookPath(cmd[0])
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func extKey(path string) string {
|
||||
return strings.ToLower(filepath.Ext(path))
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -289,7 +288,7 @@ func TestRegistryToolIsDeferredEligible(t *testing.T) {
|
||||
|
||||
// TestRegistryToolReportsMCPServerName verifies the registryTool reports its true
|
||||
// configured server name (not the sanitized tool-name token) so the deferred-tools
|
||||
// reminder labels a multi-token server correctly via tools.DeferredLine.
|
||||
// discovery label names a multi-token server correctly via tools.DeferredSource.
|
||||
func TestRegistryToolReportsMCPServerName(t *testing.T) {
|
||||
client := &fakeToolClient{}
|
||||
// A server name that sanitizes to a token containing an underscore ("git_hub"):
|
||||
@@ -305,14 +304,9 @@ func TestRegistryToolReportsMCPServerName(t *testing.T) {
|
||||
t.Fatalf("MCPServerName() = %q, want %q", tool.MCPServerName(), "git hub")
|
||||
}
|
||||
|
||||
// DeferredLine must prefer the reported server name over the name-derived token.
|
||||
line := tools.DeferredLine(tool)
|
||||
if !strings.Contains(line, "server: git hub") {
|
||||
t.Fatalf("DeferredLine = %q, want it to label server as %q via MCPServerName()", line, "git hub")
|
||||
}
|
||||
// The truncated token-only label ("git") must NOT be the server segment.
|
||||
if strings.Contains(line, "server: git |") {
|
||||
t.Fatalf("DeferredLine = %q, mislabeled multi-token server with the truncated token", line)
|
||||
// DeferredSource must prefer the reported server name over the name-derived token.
|
||||
if source := tools.DeferredSource(tool); source != "git hub" {
|
||||
t.Fatalf("DeferredSource = %q, want %q via MCPServerName()", source, "git hub")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
package modelregistry
|
||||
|
||||
import "sync"
|
||||
|
||||
// resetModelsDevCacheForTest clears the process-level cache memoization and
|
||||
// disables the overlay.
|
||||
func resetModelsDevCacheForTest() {
|
||||
modelsDevOnce = sync.Once{}
|
||||
modelsDevCached = nil
|
||||
modelsDevEnabled.Store(false)
|
||||
}
|
||||
|
||||
// Modes returns a copy of the preset catalog, preserving declaration order so
|
||||
// list output and help text stay stable.
|
||||
func Modes() []Mode {
|
||||
modes := make([]Mode, len(defaultModes))
|
||||
for index, mode := range defaultModes {
|
||||
modes[index] = cloneMode(mode)
|
||||
}
|
||||
return modes
|
||||
}
|
||||
@@ -197,14 +197,6 @@ func cachedModelsDevProviders() map[string]map[string]modelsDevModel {
|
||||
return modelsDevCached
|
||||
}
|
||||
|
||||
// resetModelsDevCacheForTest clears the process-level cache memoization and
|
||||
// disables the overlay.
|
||||
func resetModelsDevCacheForTest() {
|
||||
modelsDevOnce = sync.Once{}
|
||||
modelsDevCached = nil
|
||||
modelsDevEnabled.Store(false)
|
||||
}
|
||||
|
||||
// RefreshModelsDevCache fetches models.dev/api.json into the on-disk cache
|
||||
// when the cache is missing or older than modelsDevRefreshAfter. It is safe to
|
||||
// call fire-and-forget from startup (use a goroutine); it never affects the
|
||||
|
||||
@@ -62,16 +62,6 @@ var defaultModes = []Mode{
|
||||
},
|
||||
}
|
||||
|
||||
// Modes returns a copy of the preset catalog, preserving declaration order so
|
||||
// list output and help text stay stable.
|
||||
func Modes() []Mode {
|
||||
modes := make([]Mode, len(defaultModes))
|
||||
for index, mode := range defaultModes {
|
||||
modes[index] = cloneMode(mode)
|
||||
}
|
||||
return modes
|
||||
}
|
||||
|
||||
// LookupMode returns the preset registered under name (case-insensitive,
|
||||
// whitespace-trimmed). The second result reports whether a preset matched.
|
||||
func LookupMode(name string) (Mode, bool) {
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
// Test seams: helpers only test code uses, kept out of the production binary.
|
||||
package notify
|
||||
|
||||
// Enabled reports whether mode will ever emit a notification.
|
||||
func Enabled(mode Mode) bool {
|
||||
return mode != "" && mode != ModeOff
|
||||
}
|
||||
@@ -167,11 +167,6 @@ func sequence(mode Mode, message string) string {
|
||||
}
|
||||
}
|
||||
|
||||
// Enabled reports whether mode will ever emit a notification.
|
||||
func Enabled(mode Mode) bool {
|
||||
return mode != "" && mode != ModeOff
|
||||
}
|
||||
|
||||
// sanitizeMessage drops control bytes (so the message can't break the escape or
|
||||
// inject terminal control) and clamps to maxMessageLen runes.
|
||||
func sanitizeMessage(s string) string {
|
||||
|
||||
@@ -284,7 +284,7 @@ const skillFileName = "SKILL.md"
|
||||
|
||||
// MergedSkills loads the default skills directory, the shared ~/.agents/skills
|
||||
// root when present, plus the supplied plugin skill roots and returns one
|
||||
// merged, name-deduplicated list (Content stripped, like skills.List) alongside
|
||||
// merged, name-deduplicated list (Content stripped, like skills.ListFromRoots) alongside
|
||||
// the duplicate-name collisions across all roots. Earlier roots win a name
|
||||
// clash, matching skills.Load's first-wins rule; the default dir is always
|
||||
// considered first so a user skill shadows agents and plugins. A bad root simply
|
||||
|
||||
@@ -187,14 +187,6 @@ func All() []Descriptor {
|
||||
return copied
|
||||
}
|
||||
|
||||
func IDs() []string {
|
||||
ids := make([]string, 0, len(descriptors))
|
||||
for _, descriptor := range descriptors {
|
||||
ids = append(ids, descriptor.ID)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func Get(id string) (Descriptor, bool) {
|
||||
normalized := NormalizeID(id)
|
||||
for _, descriptor := range descriptors {
|
||||
@@ -219,35 +211,6 @@ func Require(id string) (Descriptor, error) {
|
||||
return descriptor, nil
|
||||
}
|
||||
|
||||
func ListByTransport(transport Transport) []Descriptor {
|
||||
normalized := Transport(NormalizeID(string(transport)))
|
||||
items := make([]Descriptor, 0)
|
||||
for _, descriptor := range descriptors {
|
||||
if descriptor.Transport == normalized {
|
||||
items = append(items, cloneDescriptor(descriptor))
|
||||
}
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func ValidTransport(transport Transport) bool {
|
||||
switch Transport(NormalizeID(string(transport))) {
|
||||
case TransportOpenAI, TransportAnthropic, TransportGoogle, TransportBedrock, TransportVertex, TransportOpenAICompatible, TransportAnthropicCompatible:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func ValidAPIFormat(format APIFormat) bool {
|
||||
switch format {
|
||||
case APIFormatOpenAIResponses, APIFormatOpenAIChatCompletions, APIFormatAnthropicMessages, APIFormatGoogleGenerateContent, APIFormatBedrockConverse, APIFormatVertexGenerateContent:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func NormalizeID(id string) string {
|
||||
var builder strings.Builder
|
||||
lastDash := false
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
// Test seams: helpers only test code uses, kept out of the production binary.
|
||||
package providercatalog
|
||||
|
||||
func IDs() []string {
|
||||
ids := make([]string, 0, len(descriptors))
|
||||
for _, descriptor := range descriptors {
|
||||
ids = append(ids, descriptor.ID)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func ListByTransport(transport Transport) []Descriptor {
|
||||
normalized := Transport(NormalizeID(string(transport)))
|
||||
items := make([]Descriptor, 0)
|
||||
for _, descriptor := range descriptors {
|
||||
if descriptor.Transport == normalized {
|
||||
items = append(items, cloneDescriptor(descriptor))
|
||||
}
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func ValidAPIFormat(format APIFormat) bool {
|
||||
switch format {
|
||||
case APIFormatOpenAIResponses, APIFormatOpenAIChatCompletions, APIFormatAnthropicMessages, APIFormatGoogleGenerateContent, APIFormatBedrockConverse, APIFormatVertexGenerateContent:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func ValidTransport(transport Transport) bool {
|
||||
switch Transport(NormalizeID(string(transport))) {
|
||||
case TransportOpenAI, TransportAnthropic, TransportGoogle, TransportBedrock, TransportVertex, TransportOpenAICompatible, TransportAnthropicCompatible:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,6 @@ package openai
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
@@ -207,14 +206,3 @@ func (p *CodexProvider) resolveAccount(ctx context.Context) (string, bool, error
|
||||
}
|
||||
return "", false, nil
|
||||
}
|
||||
|
||||
// ValidateAccount is a convenience for tests/callers that want to confirm the
|
||||
// account id is the right shape (non-empty, trimmed). It is a no-op helper
|
||||
// rather than a constructor check so a Codex provider can be built before the
|
||||
// first login completes.
|
||||
func ValidateAccount(account string) error {
|
||||
if strings.TrimSpace(account) == "" {
|
||||
return errors.New("openai codex: account id is empty")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
// Test seams: helpers only test code uses, kept out of the production binary.
|
||||
package openai
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ValidateAccount is a convenience for tests/callers that want to confirm the
|
||||
// account id is the right shape (non-empty, trimmed). It is a no-op helper
|
||||
// rather than a constructor check so a Codex provider can be built before the
|
||||
// first login completes.
|
||||
func ValidateAccount(account string) error {
|
||||
if strings.TrimSpace(account) == "" {
|
||||
return errors.New("openai codex: account id is empty")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -945,9 +945,9 @@ func TestStreamCompletionSurfacesLengthFinishReason(t *testing.T) {
|
||||
t.Fatalf("done FinishReason = %q, want %q", doneReason, zeroruntime.FinishReasonLength)
|
||||
}
|
||||
|
||||
// And it round-trips through the runtime collector as Truncated.
|
||||
// And it round-trips through the runtime collector's FinishReason.
|
||||
collected := zeroruntime.CollectStream(context.Background(), replay(events))
|
||||
if !collected.Truncated() || collected.FinishReason != zeroruntime.FinishReasonLength {
|
||||
if collected.FinishReason != zeroruntime.FinishReasonLength {
|
||||
t.Fatalf("collected = %+v, want truncated length", collected)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
// Test seams: helpers only test code uses, kept out of the production binary.
|
||||
package providerio
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"io"
|
||||
)
|
||||
|
||||
// ScanSSEData parses Server-Sent Event data fields from a streaming response.
|
||||
func ScanSSEData(reader io.Reader, handle func(data string) bool) error {
|
||||
scanner := bufio.NewScanner(reader)
|
||||
scanner.Buffer(make([]byte, 0, 4096), maxSSELineBytes)
|
||||
return scanSSEPayloads(scanner, handle, nil)
|
||||
}
|
||||
@@ -195,13 +195,6 @@ func SendEvent(ctx context.Context, events chan<- zeroruntime.StreamEvent, event
|
||||
}
|
||||
}
|
||||
|
||||
// ScanSSEData parses Server-Sent Event data fields from a streaming response.
|
||||
func ScanSSEData(reader io.Reader, handle func(data string) bool) error {
|
||||
scanner := bufio.NewScanner(reader)
|
||||
scanner.Buffer(make([]byte, 0, 4096), maxSSELineBytes)
|
||||
return scanSSEPayloads(scanner, handle, nil)
|
||||
}
|
||||
|
||||
// scanSSEPayloads accumulates SSE "data:" lines into payloads (joined across
|
||||
// continuation lines, flushed on a blank line or EOF) and forwards each to
|
||||
// handle. It is the shared core of ScanSSEData and the idle-aware variant.
|
||||
|
||||
@@ -1,147 +0,0 @@
|
||||
// Package reasoning models how each provider's models expose reasoning-effort
|
||||
// control. Providers disagree on the concept — OpenAI uses a discrete effort
|
||||
// enum, Anthropic and Gemini 2.5 use a thinking-token budget, some models only
|
||||
// toggle thinking on/off — so a single flat effort string cannot describe them.
|
||||
//
|
||||
// Capability is the typed, per-model description of that control, sourced from a
|
||||
// community capability catalog (models.dev). It is the data the rest of Zero
|
||||
// consults to decide which reasoning tiers a model actually supports, replacing
|
||||
// model-name guessing. This package depends only on the standard library, so the
|
||||
// model registry and provider adapters can import it without a cycle.
|
||||
//
|
||||
// Source of truth: this catalog is intended to become the authoritative reasoning
|
||||
// -capability source. When it is wired into the live path, modelregistry's
|
||||
// name-pattern inference (reasoningEffortsForModelName) is demoted to a fallback
|
||||
// for models the catalog does not cover; the catalog wins where it has an entry.
|
||||
// This commit is additive groundwork — nothing consumes it yet — so there is no
|
||||
// behavioral divergence until that wiring lands.
|
||||
package reasoning
|
||||
|
||||
import "strings"
|
||||
|
||||
// ControlKind enumerates how a model exposes reasoning control. The values
|
||||
// mirror the models.dev reasoning_options[].type field.
|
||||
type ControlKind string
|
||||
|
||||
const (
|
||||
// ControlEffort is a discrete effort enum (OpenAI reasoning_effort, Gemini 3
|
||||
// thinkingLevel, newer Claude output_config.effort).
|
||||
ControlEffort ControlKind = "effort"
|
||||
// ControlBudget is a thinking-token budget (Gemini 2.5 thinkingBudget, legacy
|
||||
// Claude thinking.budget_tokens).
|
||||
ControlBudget ControlKind = "budget_tokens"
|
||||
// ControlToggle is an on/off thinking switch with no levels.
|
||||
ControlToggle ControlKind = "toggle"
|
||||
)
|
||||
|
||||
// Control is one reasoning control a model accepts. For an effort control,
|
||||
// Values lists the accepted tiers ordered weakest to strongest. For a budget
|
||||
// control, Min/Max bound the thinking-token budget; each is nil when the
|
||||
// provider does not bound that side, kept distinct from an explicit 0 (Gemini
|
||||
// uses min: 0 to mean "thinking can be disabled", which a plain int would lose).
|
||||
type Control struct {
|
||||
Kind ControlKind `json:"type"`
|
||||
Values []string `json:"values,omitempty"`
|
||||
Min *int `json:"min,omitempty"`
|
||||
Max *int `json:"max,omitempty"`
|
||||
}
|
||||
|
||||
// clone returns a deep copy of the control so the embedded catalog cannot be
|
||||
// mutated through a returned Control's slice or budget pointers.
|
||||
func (ctrl Control) clone() Control {
|
||||
out := Control{Kind: ctrl.Kind}
|
||||
if ctrl.Values != nil {
|
||||
out.Values = append([]string(nil), ctrl.Values...)
|
||||
}
|
||||
if ctrl.Min != nil {
|
||||
min := *ctrl.Min
|
||||
out.Min = &min
|
||||
}
|
||||
if ctrl.Max != nil {
|
||||
max := *ctrl.Max
|
||||
out.Max = &max
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Capability is the reasoning capability of a single model: whether it reasons
|
||||
// at all, and through which controls. A model may carry more than one control
|
||||
// (e.g. a newer Claude model exposes both an effort enum and a token budget); a
|
||||
// reasoning model with no controls reasons but exposes no knob (always-on).
|
||||
type Capability struct {
|
||||
Reasoning bool `json:"reasoning"`
|
||||
Controls []Control `json:"reasoning_options,omitempty"`
|
||||
}
|
||||
|
||||
// clone returns a deep copy of the capability, cloning each control, so a caller
|
||||
// cannot mutate the shared catalog through the returned value's slices.
|
||||
func (c Capability) clone() Capability {
|
||||
out := Capability{Reasoning: c.Reasoning}
|
||||
if c.Controls != nil {
|
||||
out.Controls = make([]Control, len(c.Controls))
|
||||
for i, ctrl := range c.Controls {
|
||||
out.Controls[i] = ctrl.clone()
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Supported reports whether the model performs any reasoning.
|
||||
func (c Capability) Supported() bool { return c.Reasoning }
|
||||
|
||||
// EffortControl returns the model's effort control and whether it has one. The
|
||||
// returned Control is a deep copy, so a caller cannot mutate the shared catalog
|
||||
// through its Values slice.
|
||||
func (c Capability) EffortControl() (Control, bool) {
|
||||
for _, ctrl := range c.Controls {
|
||||
if ctrl.Kind == ControlEffort {
|
||||
return ctrl.clone(), true
|
||||
}
|
||||
}
|
||||
return Control{}, false
|
||||
}
|
||||
|
||||
// EffortValues returns the ordered effort tiers the model accepts, or nil when
|
||||
// it has no effort control (a budget- or toggle-only model, or a non-reasoning
|
||||
// model).
|
||||
func (c Capability) EffortValues() []string {
|
||||
if ctrl, ok := c.EffortControl(); ok {
|
||||
return ctrl.Values
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SupportsEffort reports whether tier is one of the model's accepted effort
|
||||
// values (case-insensitive).
|
||||
func (c Capability) SupportsEffort(tier string) bool {
|
||||
tier = strings.ToLower(strings.TrimSpace(tier))
|
||||
for _, v := range c.EffortValues() {
|
||||
if strings.ToLower(v) == tier {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// BudgetControl returns the model's token-budget control and whether it has one.
|
||||
// The returned Control is a deep copy: its Min/Max are independent pointers, so a
|
||||
// caller cannot mutate the shared catalog through them. Min/Max are nil when that
|
||||
// bound is unspecified (a real 0, e.g. Gemini's min: 0, is a non-nil pointer to 0).
|
||||
func (c Capability) BudgetControl() (Control, bool) {
|
||||
for _, ctrl := range c.Controls {
|
||||
if ctrl.Kind == ControlBudget {
|
||||
return ctrl.clone(), true
|
||||
}
|
||||
}
|
||||
return Control{}, false
|
||||
}
|
||||
|
||||
// HasControl reports whether the model exposes a control of the given kind.
|
||||
func (c Capability) HasControl(kind ControlKind) bool {
|
||||
for _, ctrl := range c.Controls {
|
||||
if ctrl.Kind == kind {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
package reasoning
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// modelsdev_snapshot.json is a trimmed snapshot of https://models.dev/api.json:
|
||||
// for a set of first-party providers, each model's api id mapped to its
|
||||
// reasoning capability (the `reasoning` flag and `reasoning_options`). Routers
|
||||
// (OpenRouter/Azure/etc.) are intentionally excluded — they carry generic or
|
||||
// stale option lists, so a lookup resolves to the first-party provider instead.
|
||||
//
|
||||
// Provenance is recorded in the snapshot's `_source`/`_fetched` header fields.
|
||||
// To regenerate: fetch api.json, then for each provider in
|
||||
// {anthropic, openai, google, xai, groq, deepseek} keep `{reasoning,
|
||||
// reasoning_options}` per api id, and bump `_fetched`. Runtime auto-refresh is a
|
||||
// separate change.
|
||||
//
|
||||
//go:embed modelsdev_snapshot.json
|
||||
var snapshotBytes []byte
|
||||
|
||||
// snapshotDoc is the on-disk shape of the embedded snapshot.
|
||||
type snapshotDoc struct {
|
||||
Providers map[string]map[string]Capability `json:"providers"`
|
||||
}
|
||||
|
||||
// Catalog is a reasoning-capability lookup keyed by provider slug and api model
|
||||
// id. It is read-only after construction.
|
||||
type Catalog struct {
|
||||
byProvider map[string]map[string]Capability
|
||||
}
|
||||
|
||||
// ParseCatalog builds a Catalog from a models.dev-style snapshot document.
|
||||
func ParseCatalog(data []byte) (Catalog, error) {
|
||||
var doc snapshotDoc
|
||||
if err := json.Unmarshal(data, &doc); err != nil {
|
||||
return Catalog{}, fmt.Errorf("reasoning: parse catalog: %w", err)
|
||||
}
|
||||
if len(doc.Providers) == 0 {
|
||||
return Catalog{}, fmt.Errorf("reasoning: catalog has no providers")
|
||||
}
|
||||
return Catalog{byProvider: doc.Providers}, nil
|
||||
}
|
||||
|
||||
// embedded is the catalog parsed from the bundled snapshot. A malformed embed is
|
||||
// a build/release error, so it panics at init rather than failing silently.
|
||||
var embedded = mustParseEmbedded()
|
||||
|
||||
func mustParseEmbedded() Catalog {
|
||||
c, err := ParseCatalog(snapshotBytes)
|
||||
if err != nil {
|
||||
panic("reasoning: invalid embedded snapshot: " + err.Error())
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// Embedded returns the catalog parsed from the bundled models.dev snapshot.
|
||||
func Embedded() Catalog { return embedded }
|
||||
|
||||
// Lookup returns the reasoning capability for a model identified by its Zero
|
||||
// provider kind and the API model id — the provider's wire name (e.g.
|
||||
// "claude-opus-4-1-20250805"), NOT the friendly registry id ("claude-opus-4.1").
|
||||
// The match is exact: the api model id is only trimmed, not case-folded, and no
|
||||
// router prefixes ("openai/…") or suffixes (":cloud") are stripped. ok is false
|
||||
// when the provider or model is not in the snapshot, in which case the caller
|
||||
// falls back to its next capability source.
|
||||
func (c Catalog) Lookup(provider, apiModel string) (Capability, bool) {
|
||||
apiModel = strings.TrimSpace(apiModel)
|
||||
if apiModel == "" {
|
||||
return Capability{}, false
|
||||
}
|
||||
for _, slug := range providerSlugs(provider) {
|
||||
models, ok := c.byProvider[slug]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if entry, ok := models[apiModel]; ok {
|
||||
// Deep copy so callers cannot mutate the shared catalog through the
|
||||
// returned Controls / Values slices or budget pointers.
|
||||
return entry.clone(), true
|
||||
}
|
||||
}
|
||||
return Capability{}, false
|
||||
}
|
||||
|
||||
// providerSlugs maps a Zero provider kind to the models.dev provider slug to
|
||||
// look up. Gemini and Google both resolve to "google" (AI Studio), the
|
||||
// first-party source for Gemini models.
|
||||
func providerSlugs(provider string) []string {
|
||||
switch strings.ToLower(strings.TrimSpace(provider)) {
|
||||
case "gemini":
|
||||
return []string{"google"}
|
||||
default:
|
||||
return []string{strings.ToLower(strings.TrimSpace(provider))}
|
||||
}
|
||||
}
|
||||
@@ -1,249 +0,0 @@
|
||||
package reasoning
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestEmbeddedCatalogLoads(t *testing.T) {
|
||||
c := Embedded()
|
||||
if len(c.byProvider) == 0 {
|
||||
t.Fatal("embedded catalog is empty")
|
||||
}
|
||||
if _, ok := c.Lookup("openai", "gpt-5"); !ok {
|
||||
t.Fatal("embedded catalog missing a well-known model (gpt-5)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCatalogRejectsEmpty(t *testing.T) {
|
||||
if _, err := ParseCatalog([]byte(`{}`)); err == nil {
|
||||
t.Fatal("expected an error for a catalog with no providers")
|
||||
}
|
||||
if _, err := ParseCatalog([]byte(`not json`)); err == nil {
|
||||
t.Fatal("expected an error for malformed json")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroundTruthOpenAI(t *testing.T) {
|
||||
c := Embedded()
|
||||
cases := []struct {
|
||||
api string
|
||||
reason bool
|
||||
kind ControlKind
|
||||
values []string
|
||||
}{
|
||||
{"gpt-5", true, ControlEffort, []string{"minimal", "low", "medium", "high"}},
|
||||
{"gpt-5-codex", true, ControlEffort, []string{"low", "medium", "high"}},
|
||||
{"gpt-5-pro", true, ControlEffort, []string{"high"}},
|
||||
{"gpt-5.1", true, ControlEffort, []string{"none", "low", "medium", "high"}},
|
||||
{"gpt-5.1-codex-max", true, ControlEffort, []string{"low", "medium", "high", "xhigh"}},
|
||||
{"o3", true, ControlEffort, []string{"low", "medium", "high"}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
entry, ok := c.Lookup("openai", tc.api)
|
||||
if !ok {
|
||||
t.Errorf("%s: not found", tc.api)
|
||||
continue
|
||||
}
|
||||
if entry.Supported() != tc.reason {
|
||||
t.Errorf("%s: Supported=%v want %v", tc.api, entry.Supported(), tc.reason)
|
||||
}
|
||||
if !equalStrings(entry.EffortValues(), tc.values) {
|
||||
t.Errorf("%s: efforts=%v want %v", tc.api, entry.EffortValues(), tc.values)
|
||||
}
|
||||
}
|
||||
|
||||
// Non-reasoning models report reasoning:false and expose no effort.
|
||||
for _, api := range []string{"gpt-4o", "gpt-4o-mini", "gpt-4.1", "gpt-4-turbo"} {
|
||||
entry, ok := c.Lookup("openai", api)
|
||||
if !ok {
|
||||
t.Errorf("%s: not found", api)
|
||||
continue
|
||||
}
|
||||
if entry.Supported() {
|
||||
t.Errorf("%s: Supported=true, want false (non-reasoning)", api)
|
||||
}
|
||||
if entry.EffortValues() != nil {
|
||||
t.Errorf("%s: efforts=%v, want nil", api, entry.EffortValues())
|
||||
}
|
||||
}
|
||||
|
||||
// Version splits: minimal vs none are not interchangeable.
|
||||
gpt5, _ := c.Lookup("openai", "gpt-5")
|
||||
if !gpt5.SupportsEffort("minimal") || gpt5.SupportsEffort("none") {
|
||||
t.Error("gpt-5 should support minimal, not none")
|
||||
}
|
||||
gpt51, _ := c.Lookup("openai", "gpt-5.1")
|
||||
if gpt51.SupportsEffort("minimal") || !gpt51.SupportsEffort("none") {
|
||||
t.Error("gpt-5.1 should support none, not minimal")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroundTruthAnthropic(t *testing.T) {
|
||||
c := Embedded()
|
||||
|
||||
// Legacy / budget-only models: a thinking-token budget, no effort enum. The
|
||||
// budget carries an explicit min (1024) and no max — Max stays nil.
|
||||
for _, api := range []string{
|
||||
"claude-opus-4-1-20250805", "claude-sonnet-4-5-20250929", "claude-haiku-4-5-20251001",
|
||||
} {
|
||||
entry, ok := c.Lookup("anthropic", api)
|
||||
if !ok {
|
||||
t.Errorf("%s: not found", api)
|
||||
continue
|
||||
}
|
||||
ctrl, ok := entry.BudgetControl()
|
||||
if !ok {
|
||||
t.Errorf("%s: expected a budget control", api)
|
||||
continue
|
||||
}
|
||||
if ctrl.Min == nil || *ctrl.Min != 1024 {
|
||||
t.Errorf("%s: budget Min=%v, want non-nil 1024", api, ctrl.Min)
|
||||
}
|
||||
if ctrl.Max != nil {
|
||||
t.Errorf("%s: budget Max=%d, want nil (unbounded)", api, *ctrl.Max)
|
||||
}
|
||||
if _, ok := entry.EffortControl(); ok {
|
||||
t.Errorf("%s: did not expect an effort control", api)
|
||||
}
|
||||
}
|
||||
|
||||
// Newer models: native effort enum with xhigh and the budget control removed.
|
||||
opus48, ok := c.Lookup("anthropic", "claude-opus-4-8")
|
||||
if !ok {
|
||||
t.Fatal("claude-opus-4-8 not found")
|
||||
}
|
||||
if !equalStrings(opus48.EffortValues(), []string{"low", "medium", "high", "xhigh", "max"}) {
|
||||
t.Errorf("opus-4-8 efforts=%v", opus48.EffortValues())
|
||||
}
|
||||
if _, ok := opus48.BudgetControl(); ok {
|
||||
t.Error("opus-4-8 should not carry a budget control (removed)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroundTruthGemini(t *testing.T) {
|
||||
c := Embedded()
|
||||
|
||||
pro, ok := c.Lookup("gemini", "gemini-2.5-pro") // gemini kind -> google slug
|
||||
if !ok {
|
||||
t.Fatal("gemini-2.5-pro not found via the gemini provider kind")
|
||||
}
|
||||
ctrl, ok := pro.BudgetControl()
|
||||
if !ok || ctrl.Min == nil || *ctrl.Min != 128 || ctrl.Max == nil || *ctrl.Max != 32768 {
|
||||
t.Errorf("gemini-2.5-pro budget=%+v ok=%v, want min=128 max=32768", ctrl, ok)
|
||||
}
|
||||
if _, ok := pro.EffortControl(); ok {
|
||||
t.Error("gemini-2.5-pro should be budget-only, no effort control")
|
||||
}
|
||||
|
||||
// gemini-2.5-flash can disable thinking: a toggle plus a budget whose explicit
|
||||
// min: 0 must survive (a plain int + omitempty would drop it).
|
||||
flash, _ := c.Lookup("google", "gemini-2.5-flash")
|
||||
if !flash.HasControl(ControlToggle) {
|
||||
t.Error("gemini-2.5-flash should expose a toggle (thinking can be disabled)")
|
||||
}
|
||||
if fctrl, ok := flash.BudgetControl(); !ok || fctrl.Min == nil || *fctrl.Min != 0 {
|
||||
t.Errorf("gemini-2.5-flash budget Min=%v, want a non-nil pointer to 0", fctrl.Min)
|
||||
}
|
||||
|
||||
// Gemini 3.x switched to an effort enum.
|
||||
if g3, ok := c.Lookup("google", "gemini-3-pro-preview"); ok {
|
||||
if _, ok := g3.EffortControl(); !ok {
|
||||
t.Error("gemini-3-pro-preview should expose an effort control")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCoversZeroShippedReasoningModels pins that every reasoning model Zero
|
||||
// currently ships resolves in the catalog (by its api model id), so the
|
||||
// models.dev fallback actually covers Zero's catalog rather than just well-known
|
||||
// ids. The api ids mirror internal/modelregistry's curated entries.
|
||||
func TestCoversZeroShippedReasoningModels(t *testing.T) {
|
||||
c := Embedded()
|
||||
shipped := []struct {
|
||||
provider, api string
|
||||
wantKind ControlKind
|
||||
}{
|
||||
{"anthropic", "claude-opus-4-1-20250805", ControlBudget},
|
||||
{"anthropic", "claude-sonnet-4-5-20250929", ControlBudget},
|
||||
{"anthropic", "claude-haiku-4-5-20251001", ControlBudget},
|
||||
{"google", "gemini-2.5-pro", ControlBudget},
|
||||
{"google", "gemini-2.5-flash", ControlToggle},
|
||||
{"google", "gemini-2.5-flash-lite", ControlToggle},
|
||||
}
|
||||
for _, m := range shipped {
|
||||
entry, ok := c.Lookup(m.provider, m.api)
|
||||
if !ok {
|
||||
t.Errorf("%s/%s: not covered by the snapshot", m.provider, m.api)
|
||||
continue
|
||||
}
|
||||
if !entry.Supported() {
|
||||
t.Errorf("%s/%s: Supported=false, want a reasoning model", m.provider, m.api)
|
||||
}
|
||||
if !entry.HasControl(m.wantKind) {
|
||||
t.Errorf("%s/%s: missing expected control %q (controls=%+v)", m.provider, m.api, m.wantKind, entry.Controls)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestLookupReturnsDeepCopy pins that a caller cannot corrupt the shared
|
||||
// embedded catalog by mutating a returned Capability's slices.
|
||||
func TestLookupReturnsDeepCopy(t *testing.T) {
|
||||
c := Embedded()
|
||||
first, ok := c.Lookup("openai", "gpt-5")
|
||||
if !ok || len(first.Controls) == 0 || len(first.Controls[0].Values) == 0 {
|
||||
t.Fatal("setup: gpt-5 should have an effort control with values")
|
||||
}
|
||||
first.Controls[0].Values[0] = "MUTATED"
|
||||
first.Controls[0].Kind = "MUTATED"
|
||||
|
||||
second, _ := c.Lookup("openai", "gpt-5")
|
||||
if second.Controls[0].Values[0] == "MUTATED" || second.Controls[0].Kind == "MUTATED" {
|
||||
t.Error("Lookup leaked a shared reference; a caller's mutation reached the catalog")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAccessorsReturnDeepCopies pins that EffortControl/BudgetControl hand back
|
||||
// independent copies, so a caller holding a Capability that shares state with the
|
||||
// catalog cannot corrupt it by mutating an accessor result's slice or pointers.
|
||||
func TestAccessorsReturnDeepCopies(t *testing.T) {
|
||||
min := 100
|
||||
c := Capability{Reasoning: true, Controls: []Control{
|
||||
{Kind: ControlEffort, Values: []string{"low", "high"}},
|
||||
{Kind: ControlBudget, Min: &min},
|
||||
}}
|
||||
|
||||
eff, _ := c.EffortControl()
|
||||
eff.Values[0] = "MUTATED"
|
||||
if c.Controls[0].Values[0] != "low" {
|
||||
t.Error("EffortControl leaked the Values slice")
|
||||
}
|
||||
|
||||
bud, _ := c.BudgetControl()
|
||||
*bud.Min = 999
|
||||
if *c.Controls[1].Min != 100 {
|
||||
t.Error("BudgetControl leaked the Min pointer")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupMissFallsThrough(t *testing.T) {
|
||||
c := Embedded()
|
||||
if _, ok := c.Lookup("openai", "totally-made-up-model"); ok {
|
||||
t.Error("unknown model should miss")
|
||||
}
|
||||
if _, ok := c.Lookup("no-such-provider", "gpt-5"); ok {
|
||||
t.Error("unknown provider should miss")
|
||||
}
|
||||
if _, ok := c.Lookup("openai", " "); ok {
|
||||
t.Error("blank api model should miss")
|
||||
}
|
||||
}
|
||||
|
||||
func equalStrings(a, b []string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,65 +1 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
// Test seams: helpers only test code uses, kept out of the production binary.
|
||||
package repomap
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/workspaceindex"
|
||||
)
|
||||
|
||||
func handleWalkError(cleanRoot string, current string, entry fs.DirEntry, walkErr error, truncated *bool) (bool, error) {
|
||||
return workspaceindex.HandleWalkError(cleanRoot, current, entry, walkErr, truncated)
|
||||
}
|
||||
@@ -2,7 +2,6 @@
|
||||
package repomap
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
@@ -82,10 +81,6 @@ func Scan(root string, options Options) (Snapshot, error) {
|
||||
return snapshot, err
|
||||
}
|
||||
|
||||
func handleWalkError(cleanRoot string, current string, entry fs.DirEntry, walkErr error, truncated *bool) (bool, error) {
|
||||
return workspaceindex.HandleWalkError(cleanRoot, current, entry, walkErr, truncated)
|
||||
}
|
||||
|
||||
func fileDepth(rel string) int {
|
||||
return workspaceindex.FileDepth(rel)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
// Test seams: helpers only test code uses, kept out of the production binary.
|
||||
package review
|
||||
|
||||
func HasBlockingChecks(checks []Check) bool {
|
||||
for _, check := range checks {
|
||||
if IsBlocking(check.Outcome) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -76,15 +76,6 @@ func BuildChecksFromEnv(env map[string]string) []Check {
|
||||
return checks
|
||||
}
|
||||
|
||||
func HasBlockingChecks(checks []Check) bool {
|
||||
for _, check := range checks {
|
||||
if IsBlocking(check.Outcome) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func IsBlocking(outcome Outcome) bool {
|
||||
switch outcome {
|
||||
case OutcomeFailure, OutcomeCancelled, OutcomeTimedOut, OutcomeActionRequired, OutcomeUnknown:
|
||||
|
||||
@@ -27,11 +27,22 @@ var destructivePrograms = map[string]bool{
|
||||
"mkfs": true, "fdisk": true, "shred": true, "dd": true, "parted": true,
|
||||
}
|
||||
|
||||
var powerShellRemoveItemPrograms = map[string]bool{
|
||||
"remove-item": true,
|
||||
"ri": true,
|
||||
"rd": true,
|
||||
"rmdir": true,
|
||||
"del": true,
|
||||
"erase": true,
|
||||
"rm": true,
|
||||
}
|
||||
|
||||
// networkPrograms are commands that perform network egress/ingress.
|
||||
var networkPrograms = map[string]bool{
|
||||
"curl": true, "wget": true, "ssh": true, "scp": true, "sftp": true,
|
||||
"rsync": true, "nc": true, "ncat": true, "netcat": true, "telnet": true,
|
||||
"ftp": true,
|
||||
"ftp": true, "iwr": true, "irm": true, "invoke-webrequest": true,
|
||||
"invoke-restmethod": true,
|
||||
}
|
||||
|
||||
var localServerPrograms = map[string]bool{
|
||||
@@ -172,7 +183,10 @@ func analyzeInto(script string, result *AnalysisResult, seen map[string]bool, de
|
||||
if commandUsesNetwork(prog, rest) {
|
||||
result.Network = true
|
||||
}
|
||||
if destructivePrograms[prog] || (prog == "rm" && hasRecursiveForce(rest)) || (prog == "find" && hasFindDelete(rest)) {
|
||||
if destructivePrograms[prog] ||
|
||||
(prog == "rm" && hasRecursiveForce(rest)) ||
|
||||
(powerShellRemoveItemPrograms[prog] && hasPowerShellRecursiveForce(rest)) ||
|
||||
(prog == "find" && hasFindDelete(rest)) {
|
||||
result.Destructive = true
|
||||
}
|
||||
return true
|
||||
@@ -479,6 +493,19 @@ func hasRecursiveForce(args []*syntax.Word) bool {
|
||||
return recursive && force
|
||||
}
|
||||
|
||||
func hasPowerShellRecursiveForce(args []*syntax.Word) bool {
|
||||
recursive, force := false, false
|
||||
for _, arg := range args {
|
||||
switch strings.ToLower(wordText(arg)) {
|
||||
case "-recurse", "-r":
|
||||
recursive = true
|
||||
case "-force":
|
||||
force = true
|
||||
}
|
||||
}
|
||||
return recursive && force
|
||||
}
|
||||
|
||||
func hasFindDelete(args []*syntax.Word) bool {
|
||||
for _, arg := range args {
|
||||
if wordText(arg) == "-delete" {
|
||||
|
||||
@@ -45,6 +45,27 @@ func TestAnalyzeCommand(t *testing.T) {
|
||||
{name: "sudo dynamic flag then rm -rf", script: `sudo "$maybe" rm -rf /tmp/x`, destructive: true},
|
||||
|
||||
{name: "curl", script: "curl https://example.com", network: true},
|
||||
{name: "PowerShell iwr alias", script: "iwr https://example.com", network: true},
|
||||
{name: "PowerShell irm alias", script: "irm https://example.com", network: true},
|
||||
{name: "PowerShell Invoke-WebRequest", script: "Invoke-WebRequest https://example.com", network: true},
|
||||
{name: "PowerShell Invoke-RestMethod", script: "Invoke-RestMethod https://example.com", network: true},
|
||||
{name: "PowerShell Remove-Item recursive force", script: `Remove-Item -Recurse -Force 'C:\temp\x'`, destructive: true},
|
||||
{name: "PowerShell rm recursive force", script: `rm -Recurse -Force 'C:\temp\x'`, destructive: true},
|
||||
{name: "PowerShell ri recursive force", script: `ri -Recurse -Force 'C:\temp\x'`, destructive: true},
|
||||
{name: "PowerShell rd recursive force", script: `rd -Recurse -Force 'C:\temp\x'`, destructive: true},
|
||||
{name: "PowerShell rmdir recursive force", script: `rmdir -Recurse -Force 'C:\temp\x'`, destructive: true},
|
||||
{name: "PowerShell del recursive force", script: `del -Recurse -Force 'C:\temp\x'`, destructive: true},
|
||||
{name: "PowerShell erase recursive force", script: `erase -Recurse -Force 'C:\temp\x'`, destructive: true},
|
||||
{name: "PowerShell ambiguous force abbreviation", script: `Remove-Item -Recurse -f 'C:\temp\x'`, destructive: false},
|
||||
{name: "PowerShell Remove-Item without recurse", script: `Remove-Item -Force 'C:\temp\x'`, destructive: false},
|
||||
{name: "Windows curl cmd", script: "curl.cmd https://example.com", network: true},
|
||||
{name: "Windows curl exe", script: "curl.exe https://example.com", network: true},
|
||||
{name: "Windows drive path curl exe", script: `'C:\tools\curl.exe' https://example.com`, network: true},
|
||||
{name: "Windows UNC path curl exe", script: `'\\server\share\curl.exe' https://example.com`, network: true},
|
||||
{name: "Windows dot relative path curl exe", script: `'.\curl.exe' https://example.com`, network: true},
|
||||
{name: "Windows relative path curl exe", script: `'tools\curl.exe' https://example.com`, network: true},
|
||||
{name: "Windows drive relative path curl exe", script: `'C:curl.exe' https://example.com`, network: true},
|
||||
{name: "Windows npm cmd", script: "npm.cmd install", network: true},
|
||||
{name: "wget piped to shell", script: "wget -qO- https://x.test | sh", network: true},
|
||||
{name: "python http server", script: "python3 -m http.server 8000", network: true},
|
||||
{name: "python pip install", script: "python3 -m pip install requests", network: true},
|
||||
|
||||
@@ -2,6 +2,7 @@ package sandbox
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -73,6 +74,25 @@ func TestEngineBashAllowGrantDoesNotBypassNetworkPrompt(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEngineClassifiesPowerShellCurlCommandAsNetwork(t *testing.T) {
|
||||
engine := NewEngine(EngineOptions{
|
||||
WorkspaceRoot: t.TempDir(),
|
||||
Policy: DefaultPolicy(),
|
||||
})
|
||||
decision := engine.Evaluate(context.Background(), Request{
|
||||
ToolName: "bash",
|
||||
SideEffect: SideEffectShell,
|
||||
Permission: PermissionPrompt,
|
||||
PermissionMode: PermissionModeAsk,
|
||||
Args: map[string]any{
|
||||
"command": `$env:PATH = '.;' + $env:PATH; curl.cmd https://example.com`,
|
||||
},
|
||||
})
|
||||
if decision.Action != ActionPrompt || decision.Reason != ReasonNetworkBlocked || !HasRiskCategory(decision.Risk, "network") {
|
||||
t.Fatalf("PowerShell curl command decision = %#v, want network prompt", decision)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnsandboxedExecutionAllowedPreservesDeniedReads(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
policy := DefaultPolicy()
|
||||
@@ -485,6 +505,28 @@ func TestEngineDeniesOutOfWorkspacePaths(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnginePromptsForOutOfWorkspaceReadWithReadSpecificReason(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
outside := outsideDefaultTempPath(root, "external")
|
||||
engine := NewEngine(EngineOptions{WorkspaceRoot: root, Policy: DefaultPolicy()})
|
||||
|
||||
decision := engine.Evaluate(context.Background(), Request{
|
||||
ToolName: "list_directory",
|
||||
SideEffect: SideEffectRead,
|
||||
Permission: PermissionAllow,
|
||||
WorkspaceRoot: root,
|
||||
Args: map[string]any{"path": outside},
|
||||
})
|
||||
|
||||
want := fmt.Sprintf("Reading %s requires access outside the workspace.", outside)
|
||||
if decision.Action != ActionPrompt || decision.Block == nil || !decision.Block.Recoverable {
|
||||
t.Fatalf("outside read decision = %#v, want recoverable prompt", decision)
|
||||
}
|
||||
if decision.Reason != want || decision.Block.Reason != want {
|
||||
t.Fatalf("outside read reasons = decision %q, block %q; want %q", decision.Reason, decision.Block.Reason, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnginePrecheckReportsBlocksBeforeExecution(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
outside := outsideDefaultTempPath(root, "escape.txt")
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
// Test seams: helpers only test code uses, kept out of the production binary.
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func FormatGrantList(grants []Grant) string {
|
||||
return FormatGrantListWithCommandPrefixes(grants, nil)
|
||||
}
|
||||
|
||||
func DefaultPermissionProfile(workspaceRoot string) PermissionProfile {
|
||||
return PermissionProfileFromPolicy(workspaceRoot, DefaultPolicy(), nil)
|
||||
}
|
||||
|
||||
// writeRoots returns the full ordered write-root list for command plans:
|
||||
// the workspace root plus any granted extra roots. The single-root fallback
|
||||
// only applies to engines built without a workspace root (NewEngine always
|
||||
// builds a scope otherwise); it is kept as defense in depth.
|
||||
func (engine *Engine) writeRoots(workspaceRoot string) []string {
|
||||
var roots []string
|
||||
if engine.scope != nil {
|
||||
roots = engine.scope.Roots()
|
||||
} else {
|
||||
roots = []string{workspaceRoot}
|
||||
}
|
||||
// Reflect the policy's AllowWrite roots in the OS backend write binds so a
|
||||
// sandboxed shell command may write where the policy grants writes. DenyWrite
|
||||
// is enforced at the policy gate, and on sandbox-exec additionally as an
|
||||
// explicit deny rule (see sandboxExecProfile).
|
||||
policy := engine.effectivePolicy(engine.policy)
|
||||
if extra := resolveWriteRootPaths(policy.AllowWrite); len(extra) > 0 {
|
||||
roots = dedupeStrings(append(roots, extra...))
|
||||
}
|
||||
return roots
|
||||
}
|
||||
|
||||
func sandboxEnvironment(policy Policy, backend BackendName, workspaceRoot string) []string {
|
||||
return sandboxEnvironmentForCommand(nil, policy, backend, workspaceRoot)
|
||||
}
|
||||
|
||||
func sandboxExecProfile(writeRoots []string, policy Policy, denialTag string) string {
|
||||
return seatbeltProfileFromPermissionProfile(seatbeltCompatibilityPermissionProfile(writeRoots, policy), policy, denialTag)
|
||||
}
|
||||
|
||||
func seatbeltCompatibilityPermissionProfile(writeRoots []string, policy Policy) PermissionProfile {
|
||||
fs := FileSystemPolicy{
|
||||
Kind: FileSystemUnrestricted,
|
||||
ReadRoots: []string{string(filepath.Separator)},
|
||||
IncludePlatformRoots: true,
|
||||
AllowTemp: true,
|
||||
}
|
||||
if policy.EnforceWorkspace {
|
||||
fs.Kind = FileSystemRestricted
|
||||
fs.WriteRoots = make([]WritableRoot, 0, len(writeRoots))
|
||||
for _, root := range writeRoots {
|
||||
fs.WriteRoots = append(fs.WriteRoots, WritableRoot{Root: root})
|
||||
}
|
||||
}
|
||||
fs.DenyRead = dedupeStrings(append(normalizeProfilePaths(policy.DenyRead), credentialDenyReadPaths(policy)...))
|
||||
fs.DenyWrite = normalizeProfilePaths(policy.DenyWrite)
|
||||
return PermissionProfile{
|
||||
FileSystem: fs,
|
||||
Network: NetworkPolicy{Mode: policy.Network},
|
||||
}
|
||||
}
|
||||
|
||||
// WindowsSandboxSetupPathForRunner derives the setup helper's path from a
|
||||
// standalone command-runner path (the sibling .exe in the release layout).
|
||||
// Retained for that layout; self-dispatch callers use
|
||||
// ResolveWindowsSandboxSetupHelper instead.
|
||||
func WindowsSandboxSetupPathForRunner(runnerPath string) string {
|
||||
if strings.TrimSpace(runnerPath) == "" {
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(filepath.Dir(runnerPath), WindowsSandboxSetupName)
|
||||
}
|
||||
@@ -427,10 +427,6 @@ func (store *GrantStore) Clear() (int, error) {
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func FormatGrantList(grants []Grant) string {
|
||||
return FormatGrantListWithCommandPrefixes(grants, nil)
|
||||
}
|
||||
|
||||
func FormatGrantListWithCommandPrefixes(grants []Grant, prefixes []CommandPrefixGrant) string {
|
||||
if len(grants) == 0 && len(prefixes) == 0 {
|
||||
return "No persistent sandbox grants."
|
||||
|
||||
@@ -69,10 +69,6 @@ func gitMetadataWriteCarveouts(root string) []string {
|
||||
}
|
||||
}
|
||||
|
||||
func DefaultPermissionProfile(workspaceRoot string) PermissionProfile {
|
||||
return PermissionProfileFromPolicy(workspaceRoot, DefaultPolicy(), nil)
|
||||
}
|
||||
|
||||
func PermissionProfileFromPolicy(workspaceRoot string, policy Policy, scope *Scope) PermissionProfile {
|
||||
if policy.Mode == "" {
|
||||
policy = DefaultPolicy()
|
||||
|
||||
@@ -143,28 +143,6 @@ func (engine *Engine) PrepareExecution(ctx context.Context, request execution.Re
|
||||
}, nil
|
||||
}
|
||||
|
||||
// writeRoots returns the full ordered write-root list for command plans:
|
||||
// the workspace root plus any granted extra roots. The single-root fallback
|
||||
// only applies to engines built without a workspace root (NewEngine always
|
||||
// builds a scope otherwise); it is kept as defense in depth.
|
||||
func (engine *Engine) writeRoots(workspaceRoot string) []string {
|
||||
var roots []string
|
||||
if engine.scope != nil {
|
||||
roots = engine.scope.Roots()
|
||||
} else {
|
||||
roots = []string{workspaceRoot}
|
||||
}
|
||||
// Reflect the policy's AllowWrite roots in the OS backend write binds so a
|
||||
// sandboxed shell command may write where the policy grants writes. DenyWrite
|
||||
// is enforced at the policy gate, and on sandbox-exec additionally as an
|
||||
// explicit deny rule (see sandboxExecProfile).
|
||||
policy := engine.effectivePolicy(engine.policy)
|
||||
if extra := resolveWriteRootPaths(policy.AllowWrite); len(extra) > 0 {
|
||||
roots = dedupeStrings(append(roots, extra...))
|
||||
}
|
||||
return roots
|
||||
}
|
||||
|
||||
func (engine *Engine) BuildCommandPlan(spec CommandSpec) (CommandPlan, error) {
|
||||
if engine == nil {
|
||||
return directCommandPlan(spec, Backend{Name: BackendUnavailable, Message: "sandbox disabled"}, Policy{}, ""), nil
|
||||
@@ -453,32 +431,6 @@ func seatbeltCommandPlanWithProfile(spec CommandSpec, workspaceRoot string, prof
|
||||
return plan
|
||||
}
|
||||
|
||||
func seatbeltCompatibilityPermissionProfile(writeRoots []string, policy Policy) PermissionProfile {
|
||||
fs := FileSystemPolicy{
|
||||
Kind: FileSystemUnrestricted,
|
||||
ReadRoots: []string{string(filepath.Separator)},
|
||||
IncludePlatformRoots: true,
|
||||
AllowTemp: true,
|
||||
}
|
||||
if policy.EnforceWorkspace {
|
||||
fs.Kind = FileSystemRestricted
|
||||
fs.WriteRoots = make([]WritableRoot, 0, len(writeRoots))
|
||||
for _, root := range writeRoots {
|
||||
fs.WriteRoots = append(fs.WriteRoots, WritableRoot{Root: root})
|
||||
}
|
||||
}
|
||||
fs.DenyRead = dedupeStrings(append(normalizeProfilePaths(policy.DenyRead), credentialDenyReadPaths(policy)...))
|
||||
fs.DenyWrite = normalizeProfilePaths(policy.DenyWrite)
|
||||
return PermissionProfile{
|
||||
FileSystem: fs,
|
||||
Network: NetworkPolicy{Mode: policy.Network},
|
||||
}
|
||||
}
|
||||
|
||||
func sandboxEnvironment(policy Policy, backend BackendName, workspaceRoot string) []string {
|
||||
return sandboxEnvironmentForCommand(nil, policy, backend, workspaceRoot)
|
||||
}
|
||||
|
||||
func sandboxEnvironmentForCommand(specEnv []string, policy Policy, backend BackendName, workspaceRoot string) []string {
|
||||
return sandboxEnvironmentForCommandWithSensitiveEnv(specEnv, policy, backend, workspaceRoot, nil)
|
||||
}
|
||||
@@ -664,10 +616,6 @@ func sandboxMachLookupRule() string {
|
||||
return "(allow mach-lookup\n " + strings.Join(filters, "\n ") + ")"
|
||||
}
|
||||
|
||||
func sandboxExecProfile(writeRoots []string, policy Policy, denialTag string) string {
|
||||
return seatbeltProfileFromPermissionProfile(seatbeltCompatibilityPermissionProfile(writeRoots, policy), policy, denialTag)
|
||||
}
|
||||
|
||||
func seatbeltProfileFromPermissionProfile(profile PermissionProfile, policy Policy, denialTag string) string {
|
||||
networkRule := networkRuleForProfile(profile.Network)
|
||||
readRule := seatbeltReadRule(profile.FileSystem)
|
||||
|
||||
@@ -266,6 +266,74 @@ func TestWindowsRestrictedTokenNestedPipeCapture(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestWindowsRestrictedTokenPowerShell exercises the default Windows shell
|
||||
// shape end to end under the write-restricted token. It covers PowerShell
|
||||
// initialization, a native pipeline (which creates IPC objects), UTF-8 output,
|
||||
// and workspace write enforcement.
|
||||
func TestWindowsRestrictedTokenPowerShell(t *testing.T) {
|
||||
if os.Getenv("ZERO_SANDBOX_REAL_SMOKE") != "1" {
|
||||
t.Skip("set ZERO_SANDBOX_REAL_SMOKE=1 to run real Windows sandbox smoke tests")
|
||||
}
|
||||
runnerExe := realSmokeExecutable(t, "ZERO_WINDOWS_COMMAND_RUNNER_EXE", WindowsSandboxCommandRunnerName)
|
||||
powerShell := realSmokePowerShell(t)
|
||||
|
||||
root := t.TempDir()
|
||||
outside := t.TempDir()
|
||||
sandboxHome := filepath.Join(root, ".zero-sandbox")
|
||||
profile := PermissionProfile{
|
||||
FileSystem: FileSystemPolicy{
|
||||
Kind: FileSystemRestricted,
|
||||
ReadRoots: []string{root},
|
||||
WriteRoots: []WritableRoot{{Root: root}},
|
||||
IncludePlatformRoots: true,
|
||||
AllowTemp: true,
|
||||
},
|
||||
Network: NetworkPolicy{Mode: NetworkDeny},
|
||||
}
|
||||
config := WindowsSandboxCommandArgsOptions{
|
||||
SandboxHome: sandboxHome,
|
||||
CommandCWD: root,
|
||||
WorkspaceRoots: []string{root},
|
||||
PermissionProfile: profile,
|
||||
SandboxLevel: WindowsSandboxLevelUnelevated,
|
||||
}
|
||||
|
||||
insideMarker := filepath.Join(root, "powershell-ok.txt")
|
||||
script := "$ErrorActionPreference='Stop'; " +
|
||||
"[Console]::OutputEncoding=[System.Text.Encoding]::UTF8; " +
|
||||
"$values = 1,2,3 | ForEach-Object { $_ * 2 }; " +
|
||||
"[IO.File]::WriteAllText(" + powershellSingleQuote(insideMarker) + ", (($values -join ',') + ' ✓'))"
|
||||
runWindowsRealSmokeCommand(t, runnerExe, config, []string{
|
||||
powerShell, "-NoLogo", "-NoProfile", "-Command", script,
|
||||
}, 0)
|
||||
if bytes, err := os.ReadFile(insideMarker); err != nil || strings.TrimSpace(string(bytes)) != "2,4,6 ✓" {
|
||||
t.Fatalf("PowerShell sandbox marker = %q, %v; want pipeline output", bytes, err)
|
||||
}
|
||||
|
||||
outsideMarker := filepath.Join(outside, "powershell-denied.txt")
|
||||
deniedScript := "$ErrorActionPreference='Stop'; " +
|
||||
"[IO.File]::WriteAllText(" + powershellSingleQuote(outsideMarker) + ", 'leaked')"
|
||||
runWindowsRealSmokeCommand(t, runnerExe, config, []string{
|
||||
powerShell, "-NoLogo", "-NoProfile", "-Command", deniedScript,
|
||||
}, 1)
|
||||
if _, err := os.Stat(outsideMarker); err == nil {
|
||||
t.Fatal("sandboxed PowerShell wrote outside every granted root")
|
||||
} else if !os.IsNotExist(err) {
|
||||
t.Fatalf("stat outside PowerShell marker: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func realSmokePowerShell(t *testing.T) string {
|
||||
t.Helper()
|
||||
for _, candidate := range []string{"pwsh.exe", "pwsh", "powershell.exe", "powershell"} {
|
||||
if path, err := exec.LookPath(candidate); err == nil {
|
||||
return path
|
||||
}
|
||||
}
|
||||
t.Skip("PowerShell is unavailable")
|
||||
return ""
|
||||
}
|
||||
|
||||
func realSmokeExecutable(t *testing.T, envKey string, fallbackName string) string {
|
||||
t.Helper()
|
||||
if path := os.Getenv(envKey); path != "" {
|
||||
|
||||
@@ -430,25 +430,70 @@ func shellDashCPayload(program string, fields []string) string {
|
||||
// name: it strips shell quoting/escaping characters (", ', `, \) wherever they
|
||||
// appear in the token (including embedded ones like `vi\m` or `v"i"m`), strips
|
||||
// leading command-substitution markers, removes any directory prefix (so
|
||||
// /usr/bin/vim and C:\tools\vim.exe match "vim"), and lowercases. This closes
|
||||
// path/quote/substitution evasions of the detector.
|
||||
// /usr/bin/vim and C:\tools\vim.exe match "vim"), removes Windows executable
|
||||
// suffixes, and lowercases. This closes path/quote/substitution evasions of the
|
||||
// detector and keeps curl.exe/npm.cmd equivalent to curl/npm for risk analysis.
|
||||
func normalizeProgramToken(field string) string {
|
||||
token := strings.TrimSpace(field)
|
||||
token = strings.TrimLeft(token, "$(")
|
||||
token = strings.TrimRight(token, ")")
|
||||
// Strip shell quoting/escaping characters (", ', `, \) wherever they appear
|
||||
// in the token — surrounding, embedded, or as a mid-word escape — so
|
||||
// "vim", v"i"m, 'v'im, and vi\m all collapse to the program name. This is
|
||||
// done BEFORE the directory-prefix trim so an escape can't masquerade as a
|
||||
// path separator (e.g. vi\m must become vim, not m).
|
||||
token = stripChars(token, "\"'`\\")
|
||||
// Strip a directory prefix so /usr/bin/vim reduces to the basename. (A
|
||||
// Windows-style backslash path separator is already removed above, so only
|
||||
// the POSIX separator remains to split on.)
|
||||
if i := strings.LastIndex(token, "/"); i >= 0 {
|
||||
token = token[i+1:]
|
||||
// PowerShell commonly invokes drive-letter, UNC, and relative paths. Extract
|
||||
// their basename while backslashes still carry path-separator meaning;
|
||||
// removing them first would turn C:\tools\curl.exe into c:toolscurl.exe and
|
||||
// bypass the canonical curl risk entry.
|
||||
pathToken := stripChars(token, "\"'`")
|
||||
if windowsPathBasename, ok := windowsExecutablePathBasename(pathToken); ok {
|
||||
token = windowsPathBasename
|
||||
} else {
|
||||
// Strip shell quoting/escaping characters (", ', `, \) wherever they
|
||||
// appear in the token — surrounding, embedded, or as a mid-word escape
|
||||
// — so "vim", v"i"m, 'v'im, and vi\m all collapse to the program name.
|
||||
token = stripChars(token, "\"'`\\")
|
||||
// Strip a directory prefix so /usr/bin/vim reduces to the basename.
|
||||
if i := strings.LastIndex(token, "/"); i >= 0 {
|
||||
token = token[i+1:]
|
||||
}
|
||||
}
|
||||
return strings.ToLower(token)
|
||||
token = strings.ToLower(token)
|
||||
for _, suffix := range []string{".exe", ".cmd", ".bat", ".com"} {
|
||||
if strings.HasSuffix(token, suffix) {
|
||||
return strings.TrimSuffix(token, suffix)
|
||||
}
|
||||
}
|
||||
return token
|
||||
}
|
||||
|
||||
func windowsExecutablePathBasename(token string) (string, bool) {
|
||||
drivePath := len(token) >= 3 &&
|
||||
((token[0] >= 'a' && token[0] <= 'z') || (token[0] >= 'A' && token[0] <= 'Z')) &&
|
||||
token[1] == ':'
|
||||
uncPath := strings.HasPrefix(token, `\\`)
|
||||
explicitRelativePath := strings.HasPrefix(token, `.\`) || strings.HasPrefix(token, `..\`)
|
||||
backslashRelativePath := strings.ContainsRune(token, '\\') && hasWindowsExecutableSuffix(token)
|
||||
if !drivePath && !uncPath && !explicitRelativePath && !backslashRelativePath {
|
||||
return "", false
|
||||
}
|
||||
index := strings.LastIndexAny(token, `\/`)
|
||||
if index >= 0 {
|
||||
if index+1 >= len(token) {
|
||||
return "", false
|
||||
}
|
||||
return token[index+1:], true
|
||||
}
|
||||
if drivePath && len(token) > 2 {
|
||||
return token[2:], true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func hasWindowsExecutableSuffix(token string) bool {
|
||||
token = strings.ToLower(token)
|
||||
for _, suffix := range []string{".exe", ".cmd", ".bat", ".com"} {
|
||||
if strings.HasSuffix(token, suffix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// stripChars returns s with every rune in cutset removed.
|
||||
|
||||
@@ -203,11 +203,24 @@ func dedupeScopeRoots(roots []string) []string {
|
||||
// only on outside_workspace results. The returned block always carries
|
||||
// the caller's original requestedPath.
|
||||
func (s *Scope) validate(requestedPath string) *pathBlock {
|
||||
return s.validateAgainstRoots(requestedPath, s.Roots())
|
||||
return withOutsideWorkspaceAccessReason(s.validateAgainstRoots(requestedPath, s.Roots()), requestedPath, SideEffectWrite)
|
||||
}
|
||||
|
||||
func (s *Scope) validateRead(requestedPath string) *pathBlock {
|
||||
return s.validateAgainstRoots(requestedPath, s.ReadRoots())
|
||||
return withOutsideWorkspaceAccessReason(s.validateAgainstRoots(requestedPath, s.ReadRoots()), requestedPath, SideEffectRead)
|
||||
}
|
||||
|
||||
func withOutsideWorkspaceAccessReason(block *pathBlock, requestedPath string, sideEffect SideEffect) *pathBlock {
|
||||
if block == nil || block.Code != BlockOutsideWorkspace || !strings.Contains(block.Reason, " is outside the workspace") {
|
||||
return block
|
||||
}
|
||||
switch sideEffect {
|
||||
case SideEffectRead:
|
||||
block.Reason = fmt.Sprintf("Reading %s requires access outside the workspace.", requestedPath)
|
||||
case SideEffectWrite, SideEffectOutOfWorkspace:
|
||||
block.Reason = fmt.Sprintf("Writing to %s requires access outside the workspace. Use /add-dir or --add-dir to allow writes there.", requestedPath)
|
||||
}
|
||||
return block
|
||||
}
|
||||
|
||||
func (s *Scope) validateAgainstRoots(requestedPath string, roots []string) *pathBlock {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
@@ -279,6 +280,45 @@ func TestScopeValidateAllowsAnyRootButRelativeOnlyWorkspace(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestScopeValidateReadExplainsReadAccessOutsideWorkspace(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
scope, err := NewScope(workspace, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("NewScope: %v", err)
|
||||
}
|
||||
|
||||
outside := outsideDefaultTempPath(workspace, "elsewhere.txt")
|
||||
block := scope.validateRead(outside)
|
||||
if block == nil {
|
||||
t.Fatal("validateRead(outside workspace) = nil, want block")
|
||||
}
|
||||
want := fmt.Sprintf("Reading %s requires access outside the workspace.", outside)
|
||||
if block.Reason != want {
|
||||
t.Fatalf("block.Reason = %q, want %q", block.Reason, want)
|
||||
}
|
||||
if strings.Contains(block.Reason, "--add-dir") || strings.Contains(block.Reason, "writes") {
|
||||
t.Fatalf("read block reason must not suggest write access: %q", block.Reason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScopeValidateWriteExplainsWriteAccessOutsideWorkspace(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
scope, err := NewScope(workspace, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("NewScope: %v", err)
|
||||
}
|
||||
|
||||
outside := outsideDefaultTempPath(workspace, "elsewhere.txt")
|
||||
block := scope.validate(outside)
|
||||
if block == nil {
|
||||
t.Fatal("validate(outside workspace) = nil, want block")
|
||||
}
|
||||
want := fmt.Sprintf("Writing to %s requires access outside the workspace. Use /add-dir or --add-dir to allow writes there.", outside)
|
||||
if block.Reason != want {
|
||||
t.Fatalf("block.Reason = %q, want %q", block.Reason, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScopeValidateKeepsSymlinkTraversalProtection(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
outside := outsideDefaultTempPath(workspace, "symlink-target")
|
||||
|
||||
@@ -45,17 +45,6 @@ type WindowsSandboxSetupMarker struct {
|
||||
NetworkFilters int `json:"networkFilters"`
|
||||
}
|
||||
|
||||
// WindowsSandboxSetupPathForRunner derives the setup helper's path from a
|
||||
// standalone command-runner path (the sibling .exe in the release layout).
|
||||
// Retained for that layout; self-dispatch callers use
|
||||
// ResolveWindowsSandboxSetupHelper instead.
|
||||
func WindowsSandboxSetupPathForRunner(runnerPath string) string {
|
||||
if strings.TrimSpace(runnerPath) == "" {
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(filepath.Dir(runnerPath), WindowsSandboxSetupName)
|
||||
}
|
||||
|
||||
func WindowsSandboxSetupMarkerPath(sandboxHome string) string {
|
||||
return filepath.Join(sandboxHome, "windows-setup.json")
|
||||
}
|
||||
|
||||
@@ -248,22 +248,6 @@ func (store *Store) readBlob(sessionID, hash string) ([]byte, error) {
|
||||
return content, nil
|
||||
}
|
||||
|
||||
// pruneOrphanBlobs removes blobs not referenced by any checkpoint event (e.g. after
|
||||
// a rewind discards later checkpoints). Best-effort; returns count removed. It
|
||||
// acquires the session lock so it cannot delete a blob that a concurrent
|
||||
// CaptureToolCheckpoint has just written but not yet referenced by its event.
|
||||
func (store *Store) pruneOrphanBlobs(sessionID string) (int, error) {
|
||||
if !ValidSessionID(sessionID) {
|
||||
return 0, fmt.Errorf("invalid zero session id %q", sessionID)
|
||||
}
|
||||
unlock, err := store.lockSession(sessionID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer unlock()
|
||||
return store.pruneOrphanBlobsLocked(sessionID)
|
||||
}
|
||||
|
||||
// pruneOrphanBlobsLocked is the body of pruneOrphanBlobs WITHOUT acquiring the
|
||||
// session lock. The caller MUST already hold store.lockSession(sessionID).
|
||||
// Holding the lock around referencedBlobs + ReadDir + Remove is what closes the
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
// Test seams: helpers only test code uses, kept out of the production binary.
|
||||
package sessions
|
||||
|
||||
import "fmt"
|
||||
|
||||
// pruneOrphanBlobs removes blobs not referenced by any checkpoint event (e.g. after
|
||||
// a rewind discards later checkpoints). Best-effort; returns count removed. It
|
||||
// acquires the session lock so it cannot delete a blob that a concurrent
|
||||
// CaptureToolCheckpoint has just written but not yet referenced by its event.
|
||||
func (store *Store) pruneOrphanBlobs(sessionID string) (int, error) {
|
||||
if !ValidSessionID(sessionID) {
|
||||
return 0, fmt.Errorf("invalid zero session id %q", sessionID)
|
||||
}
|
||||
unlock, err := store.lockSession(sessionID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer unlock()
|
||||
return store.pruneOrphanBlobsLocked(sessionID)
|
||||
}
|
||||
@@ -244,10 +244,6 @@ func RehydrateEvents(events []Event) ([]Event, error) {
|
||||
return cloneEvents(events), nil
|
||||
}
|
||||
|
||||
func ReplayEvents(events []Event) ([]Event, error) {
|
||||
return RehydrateEvents(events)
|
||||
}
|
||||
|
||||
func decodeCompactionPayload(event Event) (CompactionPayload, error) {
|
||||
var payload CompactionPayload
|
||||
if err := json.Unmarshal(event.Payload, &payload); err != nil {
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
// Test seams: helpers only test code uses, kept out of the production binary.
|
||||
package skills
|
||||
|
||||
import "strings"
|
||||
|
||||
// Info returns the named skill plus its recorded source and hash, or ok=false if
|
||||
// it is not discoverable in dir.
|
||||
func Info(dir string, name string) (SkillInfo, bool) {
|
||||
skill, ok := Get(dir, name)
|
||||
if !ok {
|
||||
return SkillInfo{}, false
|
||||
}
|
||||
info := SkillInfo{Skill: skill}
|
||||
if lock, err := ReadLock(dir); err == nil {
|
||||
if entry, found := lock[skill.Name]; found {
|
||||
info.Source = entry.Source
|
||||
info.Hash = entry.Hash
|
||||
}
|
||||
}
|
||||
return info, true
|
||||
}
|
||||
|
||||
// DiscoveryRoots returns ordered skill roots for runtime discovery: primary
|
||||
// DefaultDir, optional AgentsDir when present, then pluginRoots. Empty strings
|
||||
// are omitted. Earlier entries win on name clashes.
|
||||
func DiscoveryRoots(env map[string]string, pluginRoots []string) []string {
|
||||
return collectRoots(DefaultDir(env), AgentsDir(env), pluginRoots)
|
||||
}
|
||||
|
||||
// Duplicates returns the duplicate-name collisions Load resolved by the
|
||||
// first-directory-wins rule, so a caller can warn the user that a shadowed skill
|
||||
// was dropped. A missing directory yields no duplicates and no error.
|
||||
func Duplicates(dir string) ([]DuplicateName, error) {
|
||||
_, dups, err := load(dir)
|
||||
return dups, err
|
||||
}
|
||||
|
||||
// Get loads the named skill from dir, returning false if it is not found.
|
||||
func Get(dir string, name string) (Skill, bool) {
|
||||
loaded, err := Load(dir)
|
||||
if err != nil {
|
||||
return Skill{}, false
|
||||
}
|
||||
target := strings.TrimSpace(name)
|
||||
for _, skill := range loaded {
|
||||
if skill.Name == target {
|
||||
return skill, true
|
||||
}
|
||||
}
|
||||
return Skill{}, false
|
||||
}
|
||||
|
||||
// List loads the skills directory and returns each skill without its (possibly
|
||||
// large) Content body — handy for `zero skills` listings.
|
||||
func List(dir string) ([]Skill, error) {
|
||||
loaded, err := Load(dir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
listed := make([]Skill, 0, len(loaded))
|
||||
for _, skill := range loaded {
|
||||
skill.Content = ""
|
||||
listed = append(listed, skill)
|
||||
}
|
||||
return listed, nil
|
||||
}
|
||||
@@ -232,23 +232,6 @@ func Remove(dir string, name string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Info returns the named skill plus its recorded source and hash, or ok=false if
|
||||
// it is not discoverable in dir.
|
||||
func Info(dir string, name string) (SkillInfo, bool) {
|
||||
skill, ok := Get(dir, name)
|
||||
if !ok {
|
||||
return SkillInfo{}, false
|
||||
}
|
||||
info := SkillInfo{Skill: skill}
|
||||
if lock, err := ReadLock(dir); err == nil {
|
||||
if entry, found := lock[skill.Name]; found {
|
||||
info.Source = entry.Source
|
||||
info.Hash = entry.Hash
|
||||
}
|
||||
}
|
||||
return info, true
|
||||
}
|
||||
|
||||
// InfoFromRoots resolves the named skill across discovery roots (earlier roots
|
||||
// win). Lock source/hash are attached only when the winning skill lives under
|
||||
// primaryDir and that dir's lockfile has an entry — agents-only skills return
|
||||
|
||||
@@ -39,7 +39,7 @@ var errNotDirectory = errors.New("not a directory")
|
||||
// NOT created — a missing directory simply yields no skills.
|
||||
//
|
||||
// DefaultDir is the primary write root for install/remove/lock. Runtime discovery
|
||||
// also considers AgentsDir and plugin skill roots via DiscoveryRoots / LoadFromRoots.
|
||||
// also considers AgentsDir and plugin skill roots via LoadFromRoots.
|
||||
func DefaultDir(env map[string]string) string {
|
||||
if override := strings.TrimSpace(envValue(env, "ZERO_SKILLS_DIR")); override != "" {
|
||||
return override
|
||||
@@ -92,13 +92,6 @@ func AgentsDir(env map[string]string) string {
|
||||
return dir
|
||||
}
|
||||
|
||||
// DiscoveryRoots returns ordered skill roots for runtime discovery: primary
|
||||
// DefaultDir, optional AgentsDir when present, then pluginRoots. Empty strings
|
||||
// are omitted. Earlier entries win on name clashes.
|
||||
func DiscoveryRoots(env map[string]string, pluginRoots []string) []string {
|
||||
return collectRoots(DefaultDir(env), AgentsDir(env), pluginRoots)
|
||||
}
|
||||
|
||||
// collectRoots assembles ordered non-empty skill roots. primary is typically
|
||||
// DefaultDir (or an injected test dir); agents is typically AgentsDir's result.
|
||||
func collectRoots(primary string, agents string, pluginRoots []string) []string {
|
||||
@@ -141,14 +134,14 @@ type DuplicateName struct {
|
||||
// DETERMINISTIC by a documented rule: the skill in the lexicographically-first
|
||||
// directory name wins (os.ReadDir returns entries sorted by filename, so the
|
||||
// first one encountered is kept and later same-name duplicates are dropped).
|
||||
// This guarantees Load/List/Get always resolve a duplicated name to the same
|
||||
// winner regardless of sort stability. Use Duplicates to surface a warning about
|
||||
// any such collisions.
|
||||
// This guarantees Load always resolves a duplicated name to the same winner
|
||||
// regardless of sort stability. LoadFromRoots reports the dropped collisions
|
||||
// so callers can warn about any such shadowing.
|
||||
//
|
||||
// NOTE: Load scans one root. Runtime discovery uses LoadFromRoots /
|
||||
// DiscoveryRoots (primary DefaultDir, optional ~/.agents/skills, then plugin
|
||||
// skill roots). Prefer those multi-root helpers for agent/CLI discovery; keep
|
||||
// Load for single-dir install/write call sites.
|
||||
// NOTE: Load scans one root. Runtime discovery uses LoadFromRoots (primary
|
||||
// DefaultDir, optional ~/.agents/skills, then plugin skill roots). Prefer
|
||||
// that multi-root helper for agent/CLI discovery; keep Load for single-dir
|
||||
// install/write call sites.
|
||||
func Load(dir string) ([]Skill, error) {
|
||||
skills, _, err := load(dir)
|
||||
return skills, err
|
||||
@@ -220,14 +213,6 @@ func ListFromRoots(dirs []string) ([]Skill, []DuplicateName, error) {
|
||||
return listed, dups, nil
|
||||
}
|
||||
|
||||
// Duplicates returns the duplicate-name collisions Load resolved by the
|
||||
// first-directory-wins rule, so a caller can warn the user that a shadowed skill
|
||||
// was dropped. A missing directory yields no duplicates and no error.
|
||||
func Duplicates(dir string) ([]DuplicateName, error) {
|
||||
_, dups, err := load(dir)
|
||||
return dups, err
|
||||
}
|
||||
|
||||
// confineSkillPath resolves manifestPath through symlinks and returns the real
|
||||
// path only if it stays within rootReal (the already-symlink-resolved skills
|
||||
// root). This stops a symlinked SKILL.md — or a symlinked skill directory — from
|
||||
@@ -255,7 +240,7 @@ func confineSkillPath(rootReal string, manifestPath string) (string, bool) {
|
||||
return real, true
|
||||
}
|
||||
|
||||
// load is the shared scanner behind Load and Duplicates: it parses every
|
||||
// load is the shared scanner behind Load and LoadFromRoots: it parses every
|
||||
// SKILL.md, deduplicates by frontmatter name (first directory wins) and reports
|
||||
// the dropped collisions.
|
||||
func load(dir string) ([]Skill, []DuplicateName, error) {
|
||||
@@ -343,36 +328,6 @@ func load(dir string) ([]Skill, []DuplicateName, error) {
|
||||
return skills, duplicates, nil
|
||||
}
|
||||
|
||||
// List loads the skills directory and returns each skill without its (possibly
|
||||
// large) Content body — handy for `zero skills` listings.
|
||||
func List(dir string) ([]Skill, error) {
|
||||
loaded, err := Load(dir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
listed := make([]Skill, 0, len(loaded))
|
||||
for _, skill := range loaded {
|
||||
skill.Content = ""
|
||||
listed = append(listed, skill)
|
||||
}
|
||||
return listed, nil
|
||||
}
|
||||
|
||||
// Get loads the named skill from dir, returning false if it is not found.
|
||||
func Get(dir string, name string) (Skill, bool) {
|
||||
loaded, err := Load(dir)
|
||||
if err != nil {
|
||||
return Skill{}, false
|
||||
}
|
||||
target := strings.TrimSpace(name)
|
||||
for _, skill := range loaded {
|
||||
if skill.Name == target {
|
||||
return skill, true
|
||||
}
|
||||
}
|
||||
return Skill{}, false
|
||||
}
|
||||
|
||||
// parseSkill splits optional `---`-delimited frontmatter from the markdown body.
|
||||
// Frontmatter is a simple line parser for `name:`/`description:` keys (no YAML
|
||||
// dependency). Without frontmatter, Name defaults to the directory name and
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
// Test seams: helpers only test code uses, kept out of the production binary.
|
||||
package specialist
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/background"
|
||||
"github.com/Gitlawb/zero/internal/streamjson"
|
||||
)
|
||||
|
||||
func NewOutputTool(manager *background.Manager) *OutputTool {
|
||||
return &OutputTool{manager: manager}
|
||||
}
|
||||
|
||||
func NewStopTool(manager *background.Manager) *StopTool {
|
||||
return &StopTool{manager: manager}
|
||||
}
|
||||
|
||||
func ParseStream(reader io.Reader) ([]streamjson.Event, error) {
|
||||
// Read with a bufio.Reader rather than bufio.Scanner: a Scanner caps a single
|
||||
// token at 64 KiB/1 MiB and returns bufio.ErrTooLong past it, which aborted the
|
||||
// whole specialist run when a child emitted one large stream-json line (a big
|
||||
// tool result or final answer). ReadString has no per-line limit; the child is
|
||||
// our own trusted subprocess, so its line length is the legitimate bound.
|
||||
buffered := bufio.NewReader(reader)
|
||||
events := []streamjson.Event{}
|
||||
lineNumber := 0
|
||||
for {
|
||||
raw, readErr := buffered.ReadString('\n')
|
||||
if len(raw) > 0 {
|
||||
lineNumber++
|
||||
if line := strings.TrimSpace(raw); line != "" {
|
||||
var event streamjson.Event
|
||||
if err := json.Unmarshal([]byte(line), &event); err != nil {
|
||||
return nil, fmt.Errorf("parse stream-json line %d: %w", lineNumber, err)
|
||||
}
|
||||
if event.Type == "" {
|
||||
return nil, fmt.Errorf("parse stream-json line %d: type is required", lineNumber)
|
||||
}
|
||||
events = append(events, event)
|
||||
}
|
||||
}
|
||||
if readErr != nil {
|
||||
if readErr == io.EOF {
|
||||
break
|
||||
}
|
||||
return nil, fmt.Errorf("read stream-json output: %w", readErr)
|
||||
}
|
||||
}
|
||||
return events, nil
|
||||
}
|
||||
@@ -379,9 +379,9 @@ Prompt.`)
|
||||
|
||||
func TestKnownToolNamesMatchCoreRegistry(t *testing.T) {
|
||||
// web_search is only registered when a search backend is configured; set one so
|
||||
// CoreTools() exposes the full set this list is meant to mirror.
|
||||
// CoreToolsScoped(, nil) exposes the full set this list is meant to mirror.
|
||||
t.Setenv("ZERO_WEBSEARCH_BASE_URL", "https://search.example/api")
|
||||
core := tools.CoreTools(t.TempDir())
|
||||
core := tools.CoreToolsScoped(t.TempDir(), nil)
|
||||
got := make([]string, 0, len(knownToolNames))
|
||||
for name := range knownToolNames {
|
||||
got = append(got, name)
|
||||
|
||||
@@ -35,10 +35,6 @@ type outputParameters struct {
|
||||
TimeoutMS int
|
||||
}
|
||||
|
||||
func NewOutputTool(manager *background.Manager) *OutputTool {
|
||||
return &OutputTool{manager: manager}
|
||||
}
|
||||
|
||||
func newOutputToolWithManagerFunc(managerFunc BackgroundManagerFunc, sessionStore *sessions.Store) *OutputTool {
|
||||
return &OutputTool{managerFunc: managerFunc, SessionStore: sessionStore}
|
||||
}
|
||||
|
||||
@@ -18,10 +18,6 @@ type stopParameters struct {
|
||||
TaskID string
|
||||
}
|
||||
|
||||
func NewStopTool(manager *background.Manager) *StopTool {
|
||||
return &StopTool{manager: manager}
|
||||
}
|
||||
|
||||
func newStopToolWithManagerFunc(managerFunc BackgroundManagerFunc) *StopTool {
|
||||
return &StopTool{managerFunc: managerFunc}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
package specialist
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/streamjson"
|
||||
@@ -38,40 +35,6 @@ func (usage StreamUsage) EffectiveTotalTokens() int {
|
||||
return usage.TotalTokens
|
||||
}
|
||||
|
||||
func ParseStream(reader io.Reader) ([]streamjson.Event, error) {
|
||||
// Read with a bufio.Reader rather than bufio.Scanner: a Scanner caps a single
|
||||
// token at 64 KiB/1 MiB and returns bufio.ErrTooLong past it, which aborted the
|
||||
// whole specialist run when a child emitted one large stream-json line (a big
|
||||
// tool result or final answer). ReadString has no per-line limit; the child is
|
||||
// our own trusted subprocess, so its line length is the legitimate bound.
|
||||
buffered := bufio.NewReader(reader)
|
||||
events := []streamjson.Event{}
|
||||
lineNumber := 0
|
||||
for {
|
||||
raw, readErr := buffered.ReadString('\n')
|
||||
if len(raw) > 0 {
|
||||
lineNumber++
|
||||
if line := strings.TrimSpace(raw); line != "" {
|
||||
var event streamjson.Event
|
||||
if err := json.Unmarshal([]byte(line), &event); err != nil {
|
||||
return nil, fmt.Errorf("parse stream-json line %d: %w", lineNumber, err)
|
||||
}
|
||||
if event.Type == "" {
|
||||
return nil, fmt.Errorf("parse stream-json line %d: type is required", lineNumber)
|
||||
}
|
||||
events = append(events, event)
|
||||
}
|
||||
}
|
||||
if readErr != nil {
|
||||
if readErr == io.EOF {
|
||||
break
|
||||
}
|
||||
return nil, fmt.Errorf("read stream-json output: %w", readErr)
|
||||
}
|
||||
}
|
||||
return events, nil
|
||||
}
|
||||
|
||||
func SummarizeStream(events []streamjson.Event, processExitCode int) StreamResult {
|
||||
result := StreamResult{Events: append([]streamjson.Event(nil), events...), ExitCode: processExitCode}
|
||||
textParts := []string{}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
// Test seams: helpers only test code uses, kept out of the production binary.
|
||||
package streamjson
|
||||
|
||||
func ParsePrompt(input string) (string, error) {
|
||||
events, err := ParseInput(input)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return ResolvePrompt(events)
|
||||
}
|
||||
@@ -238,14 +238,6 @@ func ResolveImages(events []InputEvent) ([]zeroruntime.ImageBlock, error) {
|
||||
return images, nil
|
||||
}
|
||||
|
||||
func ParsePrompt(input string) (string, error) {
|
||||
events, err := ParseInput(input)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return ResolvePrompt(events)
|
||||
}
|
||||
|
||||
func validateOutputEvent(event Event) error {
|
||||
if event.SchemaVersion != SchemaVersion {
|
||||
return fmt.Errorf("schemaVersion must be %d", SchemaVersion)
|
||||
|
||||
@@ -16,10 +16,6 @@ type applyPatchTool struct {
|
||||
scope PathScope
|
||||
}
|
||||
|
||||
func NewApplyPatchTool(workspaceRoot string) Tool {
|
||||
return NewScopedApplyPatchTool(workspaceRoot, nil)
|
||||
}
|
||||
|
||||
func NewScopedApplyPatchTool(workspaceRoot string, scope PathScope) Tool {
|
||||
return applyPatchTool{
|
||||
baseTool: baseTool{
|
||||
|
||||
@@ -154,7 +154,7 @@ func TestReadFileToolAcceptsPathAliases(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeTestFile(t, filepath.Join(root, "notes.txt"), "alpha\nbeta")
|
||||
for _, key := range []string{"file", "file_path", "filepath", "filename"} {
|
||||
res := NewReadFileTool(root).Run(context.Background(), map[string]any{key: "notes.txt"})
|
||||
res := NewScopedReadFileTool(root, nil).Run(context.Background(), map[string]any{key: "notes.txt"})
|
||||
if res.Status != StatusOK {
|
||||
t.Fatalf("alias %q: expected ok, got %s: %s", key, res.Status, res.Output)
|
||||
}
|
||||
@@ -168,7 +168,7 @@ func TestListDirectoryToolAcceptsDirAliases(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeTestFile(t, filepath.Join(root, "sub", "x.txt"), "data")
|
||||
for _, key := range []string{"directory", "dir"} {
|
||||
res := NewListDirectoryTool(root).Run(context.Background(), map[string]any{key: "sub"})
|
||||
res := NewScopedListDirectoryTool(root, nil).Run(context.Background(), map[string]any{key: "sub"})
|
||||
if res.Status != StatusOK {
|
||||
t.Fatalf("alias %q: expected ok, got %s: %s", key, res.Status, res.Output)
|
||||
}
|
||||
@@ -183,7 +183,7 @@ func TestGlobToolAcceptsPatternAndCwdAliases(t *testing.T) {
|
||||
writeTestFile(t, filepath.Join(root, "sub", "a.go"), "package sub")
|
||||
// pattern aliases
|
||||
for _, key := range []string{"glob", "match", "query", "expression"} {
|
||||
res := NewGlobTool(root).Run(context.Background(), map[string]any{key: "**/*.go"})
|
||||
res := NewScopedGlobTool(root, nil).Run(context.Background(), map[string]any{key: "**/*.go"})
|
||||
if res.Status != StatusOK {
|
||||
t.Fatalf("pattern alias %q: expected ok, got %s: %s", key, res.Status, res.Output)
|
||||
}
|
||||
@@ -193,7 +193,7 @@ func TestGlobToolAcceptsPatternAndCwdAliases(t *testing.T) {
|
||||
}
|
||||
// cwd aliases (scope the scan to sub/)
|
||||
for _, key := range []string{"dir", "directory", "path"} {
|
||||
res := NewGlobTool(root).Run(context.Background(), map[string]any{"pattern": "*.go", key: "sub"})
|
||||
res := NewScopedGlobTool(root, nil).Run(context.Background(), map[string]any{"pattern": "*.go", key: "sub"})
|
||||
if res.Status != StatusOK {
|
||||
t.Fatalf("cwd alias %q: expected ok, got %s: %s", key, res.Status, res.Output)
|
||||
}
|
||||
@@ -207,7 +207,7 @@ func TestGrepToolAcceptsPatternAndPathAliases(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeTestFile(t, filepath.Join(root, "sub", "main.go"), "func main() {}\n")
|
||||
for _, key := range []string{"query", "regex", "search", "expression"} {
|
||||
res := NewGrepTool(root).Run(context.Background(), map[string]any{key: "func main"})
|
||||
res := NewScopedGrepTool(root, nil).Run(context.Background(), map[string]any{key: "func main"})
|
||||
if res.Status != StatusOK {
|
||||
t.Fatalf("pattern alias %q: expected ok, got %s: %s", key, res.Status, res.Output)
|
||||
}
|
||||
@@ -216,7 +216,7 @@ func TestGrepToolAcceptsPatternAndPathAliases(t *testing.T) {
|
||||
}
|
||||
}
|
||||
for _, key := range []string{"dir", "directory"} {
|
||||
res := NewGrepTool(root).Run(context.Background(), map[string]any{"pattern": "func main", key: "sub"})
|
||||
res := NewScopedGrepTool(root, nil).Run(context.Background(), map[string]any{"pattern": "func main", key: "sub"})
|
||||
if res.Status != StatusOK {
|
||||
t.Fatalf("path alias %q: expected ok, got %s: %s", key, res.Status, res.Output)
|
||||
}
|
||||
@@ -233,7 +233,7 @@ func TestOptionalPathArgsTreatEmptyAsDefault(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeTestFile(t, filepath.Join(root, "main.go"), "func main() {}\n")
|
||||
|
||||
grepRes := NewGrepTool(root).Run(context.Background(), map[string]any{"pattern": "func main", "path": ""})
|
||||
grepRes := NewScopedGrepTool(root, nil).Run(context.Background(), map[string]any{"pattern": "func main", "path": ""})
|
||||
if grepRes.Status != StatusOK {
|
||||
t.Fatalf("grep path:\"\" expected ok, got %s: %s", grepRes.Status, grepRes.Output)
|
||||
}
|
||||
@@ -241,7 +241,7 @@ func TestOptionalPathArgsTreatEmptyAsDefault(t *testing.T) {
|
||||
t.Fatalf("grep path:\"\" expected hit, got %q", grepRes.Output)
|
||||
}
|
||||
|
||||
globRes := NewGlobTool(root).Run(context.Background(), map[string]any{"pattern": "**/*.go", "cwd": ""})
|
||||
globRes := NewScopedGlobTool(root, nil).Run(context.Background(), map[string]any{"pattern": "**/*.go", "cwd": ""})
|
||||
if globRes.Status != StatusOK {
|
||||
t.Fatalf("glob cwd:\"\" expected ok, got %s: %s", globRes.Status, globRes.Output)
|
||||
}
|
||||
@@ -249,7 +249,7 @@ func TestOptionalPathArgsTreatEmptyAsDefault(t *testing.T) {
|
||||
t.Fatalf("glob cwd:\"\" expected match, got %q", globRes.Output)
|
||||
}
|
||||
|
||||
listRes := NewListDirectoryTool(root).Run(context.Background(), map[string]any{"path": ""})
|
||||
listRes := NewScopedListDirectoryTool(root, nil).Run(context.Background(), map[string]any{"path": ""})
|
||||
if listRes.Status != StatusOK {
|
||||
t.Fatalf("list_directory path:\"\" expected ok, got %s: %s", listRes.Status, listRes.Output)
|
||||
}
|
||||
@@ -261,7 +261,7 @@ func TestOptionalPathArgsTreatEmptyAsDefault(t *testing.T) {
|
||||
func TestWriteFileToolAcceptsPathAliases(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
for _, key := range []string{"file", "file_path", "filename"} {
|
||||
res := NewWriteFileTool(root).Run(context.Background(), map[string]any{key: key + ".txt", "content": "x"})
|
||||
res := NewScopedWriteFileTool(root, nil).Run(context.Background(), map[string]any{key: key + ".txt", "content": "x"})
|
||||
if res.Status != StatusOK {
|
||||
t.Fatalf("path alias %q: expected ok, got %s: %s", key, res.Status, res.Output)
|
||||
}
|
||||
@@ -276,7 +276,7 @@ func TestEditFileToolAcceptsAliases(t *testing.T) {
|
||||
path := filepath.Join(root, "code.go")
|
||||
writeTestFile(t, path, "const a = 1\n")
|
||||
// path/old/new aliases all at once.
|
||||
res := NewEditFileTool(root).Run(context.Background(), map[string]any{
|
||||
res := NewScopedEditFileTool(root, nil).Run(context.Background(), map[string]any{
|
||||
"file": "code.go",
|
||||
"old": "const a = 1",
|
||||
"new": "const a = 2",
|
||||
@@ -291,7 +291,7 @@ func TestEditFileToolAcceptsAliases(t *testing.T) {
|
||||
|
||||
// other alias spellings for old_string/new_string.
|
||||
writeTestFile(t, path, "const a = 1\n")
|
||||
res = NewEditFileTool(root).Run(context.Background(), map[string]any{
|
||||
res = NewScopedEditFileTool(root, nil).Run(context.Background(), map[string]any{
|
||||
"file_path": "code.go",
|
||||
"search": "const a = 1",
|
||||
"replace": "const a = 3",
|
||||
@@ -318,7 +318,7 @@ func TestApplyPatchToolAcceptsDiffAlias(t *testing.T) {
|
||||
"+new",
|
||||
"",
|
||||
}, "\n")
|
||||
res := NewApplyPatchTool(root).Run(context.Background(), map[string]any{"diff": patch})
|
||||
res := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"diff": patch})
|
||||
if res.Status != StatusOK {
|
||||
if gitApplyUnavailable(res.Output) {
|
||||
t.Skipf("git binary unavailable: %s", res.Output)
|
||||
@@ -334,7 +334,7 @@ func TestApplyPatchToolAcceptsDiffAlias(t *testing.T) {
|
||||
func TestBashToolAcceptsCommandAliases(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
for _, key := range []string{"cmd", "script", "shell"} {
|
||||
res := NewBashTool(root).Run(context.Background(), map[string]any{key: "echo hi"})
|
||||
res := NewScopedBashTool(root, nil).Run(context.Background(), map[string]any{key: "echo hi"})
|
||||
if res.Status != StatusOK {
|
||||
t.Fatalf("command alias %q: expected ok, got %s: %s", key, res.Status, res.Output)
|
||||
}
|
||||
@@ -403,7 +403,7 @@ func TestWriteFileAcceptsStringOverwrite(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// minimax-style: overwrite as the string "true".
|
||||
res := NewWriteFileTool(root).Run(context.Background(), map[string]any{
|
||||
res := NewScopedWriteFileTool(root, nil).Run(context.Background(), map[string]any{
|
||||
"path": "shop.html", "content": "new", "overwrite": "true",
|
||||
})
|
||||
if res.Status != StatusOK {
|
||||
|
||||
@@ -155,7 +155,7 @@ func TestAskUserToolRunRejectsMissingQuestions(t *testing.T) {
|
||||
|
||||
func TestCoreReadOnlyToolsIncludeAskUser(t *testing.T) {
|
||||
found := false
|
||||
for _, tool := range CoreReadOnlyTools(t.TempDir()) {
|
||||
for _, tool := range CoreReadOnlyToolsScoped(t.TempDir(), nil) {
|
||||
if tool.Name() == "ask_user" {
|
||||
found = true
|
||||
break
|
||||
|
||||
+9
-36
@@ -106,7 +106,7 @@ func (tool bashTool) run(ctx context.Context, args map[string]any, engine *zeroS
|
||||
// unsandboxed) can actually bypass the MSYS guard instead of being
|
||||
// hard-blocked by the same check it was meant to escalate past.
|
||||
commandEngine := commandEngineForSandboxPermissions(engine, sandboxPermissions)
|
||||
if issue := detectShellCommandIssue(commandText, runtime.GOOS); issue != nil && !msysGuardBypassed(issue, commandEngine) {
|
||||
if issue := detectShellCommandIssueForRuntime(commandText, detectShellRuntime(runtime.GOOS)); issue != nil && !msysGuardBypassed(issue, commandEngine) {
|
||||
return shellIssueBlockResult(*issue)
|
||||
}
|
||||
|
||||
@@ -293,23 +293,19 @@ func shellIssueBlockResult(issue shellIssue) Result {
|
||||
}
|
||||
|
||||
// buildBashCommand returns the exec.Cmd and the sandbox plan for running
|
||||
// commandText. On Windows, when the command is not wrapped by the sandbox
|
||||
// engine (plan.Wrapped == false), it also overrides the child's raw command
|
||||
// line so commandText reaches cmd.exe unescaped; see
|
||||
// zeroSandbox.WindowsShellCommandLine for why that matters. The wrapped case
|
||||
// gets the same treatment inside the sandboxed runner process itself
|
||||
// (internal/sandbox/windows_process_windows.go), since that command line is
|
||||
// built there, not here.
|
||||
// commandText. PowerShell is preferred on Windows, with cmd.exe retained as
|
||||
// the fallback. Only cmd.exe needs the raw command-line override.
|
||||
func buildBashCommand(ctx context.Context, commandText string, absoluteCwd string, engine *zeroSandbox.Engine) (*exec.Cmd, zeroSandbox.CommandPlan, error) {
|
||||
hostShell := detectShellRuntime(runtime.GOOS)
|
||||
spec := zeroSandbox.CommandSpec{
|
||||
Name: shellExecutable(),
|
||||
Args: shellArguments(commandText),
|
||||
Name: hostShell.Executable,
|
||||
Args: hostShell.arguments(commandText),
|
||||
Dir: absoluteCwd,
|
||||
}
|
||||
if engine != nil {
|
||||
command, plan, err := engine.CommandContext(ctx, spec)
|
||||
if err == nil {
|
||||
applyWindowsShellCommandLine(command, commandText, plan.Wrapped)
|
||||
applyWindowsShellCommandLine(command, commandText, plan.Wrapped, hostShell.Kind == shellKindCmd)
|
||||
}
|
||||
return command, plan, err
|
||||
}
|
||||
@@ -333,7 +329,7 @@ func buildBashCommand(ctx context.Context, commandText string, absoluteCwd strin
|
||||
}
|
||||
command := exec.CommandContext(ctx, spec.Name, spec.Args...)
|
||||
command.Dir = spec.Dir
|
||||
applyWindowsShellCommandLine(command, commandText, plan.Wrapped)
|
||||
applyWindowsShellCommandLine(command, commandText, plan.Wrapped, hostShell.Kind == shellKindCmd)
|
||||
return command, plan, nil
|
||||
}
|
||||
|
||||
@@ -385,20 +381,6 @@ func interactiveBlockResult(detection zeroSandbox.InteractiveCommandResult) Resu
|
||||
}
|
||||
}
|
||||
|
||||
func shellExecutable() string {
|
||||
if runtime.GOOS == "windows" {
|
||||
return "cmd.exe"
|
||||
}
|
||||
return "/bin/sh"
|
||||
}
|
||||
|
||||
func shellArguments(command string) []string {
|
||||
if runtime.GOOS == "windows" {
|
||||
return zeroSandbox.WindowsShellArgs(command)
|
||||
}
|
||||
return []string{"-c", command}
|
||||
}
|
||||
|
||||
func commandExitCode(err error) int {
|
||||
if err == nil {
|
||||
return 0
|
||||
@@ -453,15 +435,6 @@ const bashOutputBudgetBytes = 32 * 1024
|
||||
// the model's recovery path — cover 6× more of the output.
|
||||
const bashCaptureBudgetBytes = 96 * 1024
|
||||
|
||||
// budgetBashOutput truncates stdout and stderr to bashOutputBudgetBytes each,
|
||||
// keeping the head and tail of anything larger, and records raw/emitted byte
|
||||
// counts plus a truncated flag in meta (mirroring outputBudgetMeta's shape for
|
||||
// the read/search tools). Detection that needs the full output (sandbox-denial
|
||||
// scanning) must run on the raw strings before this is applied.
|
||||
func budgetBashOutput(stdout string, stderr string, meta map[string]string) (string, string, bool) {
|
||||
return budgetBashCapture(stdout, len(stdout), stderr, len(stderr), meta)
|
||||
}
|
||||
|
||||
// prepareBashOutput keeps direct callers on the established positional budget.
|
||||
// Registry calls retain the existing bounded capture but leave final semantic
|
||||
// reduction and spill creation to the post-redaction registry boundary.
|
||||
@@ -630,7 +603,7 @@ func truncateHeadTailWithTotal(value string, total, maxBytes int) (string, int,
|
||||
|
||||
func formatBashOutputWithShellHint(stdout string, stderr string, exitCode int, meta map[string]string) string {
|
||||
output := formatBashOutput(stdout, stderr, exitCode)
|
||||
if issue := detectShellOutputIssue(stdout+"\n"+stderr, runtime.GOOS); issue != nil {
|
||||
if issue := detectShellOutputIssueForRuntime(stdout+"\n"+stderr, detectShellRuntime(runtime.GOOS)); issue != nil {
|
||||
meta["shell_issue"] = issue.Kind
|
||||
output = appendShellIssueHint(output, *issue)
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ const permissionRequiredFragment = "Permission required for bash"
|
||||
func TestBashAutoAllowedWhenSandboxActive(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := NewRegistry()
|
||||
registry.Register(NewBashTool(root))
|
||||
registry.Register(NewScopedBashTool(root, nil))
|
||||
engine := sandbox.NewEngine(sandbox.EngineOptions{
|
||||
WorkspaceRoot: root,
|
||||
Policy: sandboxedBashPolicy(),
|
||||
@@ -59,7 +59,7 @@ func TestBashAutoAllowedWhenSandboxActive(t *testing.T) {
|
||||
func TestBashRequireEscalatedPromptsWhenSandboxActive(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := NewRegistry()
|
||||
registry.Register(NewBashTool(root))
|
||||
registry.Register(NewScopedBashTool(root, nil))
|
||||
engine := sandbox.NewEngine(sandbox.EngineOptions{
|
||||
WorkspaceRoot: root,
|
||||
Policy: sandboxedBashPolicy(),
|
||||
@@ -87,7 +87,7 @@ func TestBashRequireEscalatedPromptsWhenSandboxActive(t *testing.T) {
|
||||
func TestBashRequireEscalatedBypassesNativeSandboxAfterApproval(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := NewRegistry()
|
||||
registry.Register(NewBashTool(root))
|
||||
registry.Register(NewScopedBashTool(root, nil))
|
||||
engine := sandbox.NewEngine(sandbox.EngineOptions{
|
||||
WorkspaceRoot: root,
|
||||
Policy: sandboxedBashPolicy(),
|
||||
@@ -133,7 +133,7 @@ func TestBashRequireEscalatedKeepsSandboxWhenDeniedReadsActive(t *testing.T) {
|
||||
func TestBashStillPromptsWithoutActiveSandbox(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := NewRegistry()
|
||||
registry.Register(NewBashTool(root))
|
||||
registry.Register(NewScopedBashTool(root, nil))
|
||||
engine := sandbox.NewEngine(sandbox.EngineOptions{
|
||||
WorkspaceRoot: root,
|
||||
Policy: sandboxedBashPolicy(),
|
||||
|
||||
@@ -36,6 +36,6 @@ func hardenProcessLifetime(command *exec.Cmd) {
|
||||
}
|
||||
}
|
||||
|
||||
// applyWindowsShellCommandLine is a no-op outside Windows; there is no
|
||||
// cmd.exe command-line-parsing quirk to work around here.
|
||||
func applyWindowsShellCommandLine(command *exec.Cmd, commandText string, wrapped bool) {}
|
||||
// applyWindowsShellCommandLine is a no-op outside Windows.
|
||||
func applyWindowsShellCommandLine(command *exec.Cmd, commandText string, wrapped bool, cmdFallback bool) {
|
||||
}
|
||||
@@ -31,15 +31,12 @@ func hardenProcessLifetime(command *exec.Cmd) {
|
||||
}
|
||||
}
|
||||
|
||||
// applyWindowsShellCommandLine overrides command's raw child command line so
|
||||
// commandText reaches cmd.exe unescaped instead of auto-quoted the way
|
||||
// exec.Cmd would normally encode a single Args element. Skipped when wrapped
|
||||
// is true: the sandbox engine then routes execution through a separate
|
||||
// zero-windows-command-runner process, which builds its own child command
|
||||
// line from scratch (internal/sandbox/windows_process_windows.go) rather than
|
||||
// inheriting whatever this outer exec.Cmd is configured with.
|
||||
func applyWindowsShellCommandLine(command *exec.Cmd, commandText string, wrapped bool) {
|
||||
if wrapped {
|
||||
// applyWindowsShellCommandLine overrides the cmd.exe fallback's raw child
|
||||
// command line so commandText reaches cmd.exe unescaped. PowerShell consumes
|
||||
// ordinary argv quoting and must not take this path. Wrapped commands are
|
||||
// handled after unwrapping by the Windows sandbox runner.
|
||||
func applyWindowsShellCommandLine(command *exec.Cmd, commandText string, wrapped bool, cmdFallback bool) {
|
||||
if wrapped || !cmdFallback {
|
||||
return
|
||||
}
|
||||
command.SysProcAttr = &syscall.SysProcAttr{CmdLine: zeroSandbox.WindowsShellCommandLine(commandText)}
|
||||
|
||||
@@ -84,7 +84,7 @@ func runBashToolHelper(command string) {
|
||||
}
|
||||
|
||||
func TestCoreToolsExposeShellTools(t *testing.T) {
|
||||
toolset := CoreTools(t.TempDir())
|
||||
toolset := CoreToolsScoped(t.TempDir(), nil)
|
||||
byName := make(map[string]Tool, len(toolset))
|
||||
for _, tool := range toolset {
|
||||
byName[tool.Name()] = tool
|
||||
@@ -109,7 +109,7 @@ func TestCoreToolsExposeShellTools(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestBashToolDescribesHostShellSyntax(t *testing.T) {
|
||||
tool := NewBashTool(t.TempDir())
|
||||
tool := NewScopedBashTool(t.TempDir(), nil)
|
||||
schema := tool.Parameters()
|
||||
descriptionParts := []string{tool.Description()}
|
||||
for _, property := range schema.Properties {
|
||||
@@ -124,11 +124,15 @@ func TestBashToolDescribesHostShellSyntax(t *testing.T) {
|
||||
}
|
||||
|
||||
if runtime.GOOS == "windows" {
|
||||
if !strings.Contains(description, "cmd.exe") || !strings.Contains(description, "cwd") {
|
||||
t.Fatalf("expected Windows cmd.exe and cwd guidance in bash description, got %q", description)
|
||||
}
|
||||
if !strings.Contains(description, "double quotes") || !strings.Contains(description, `--jq ".a | b"`) {
|
||||
t.Fatalf("expected the double-quote metacharacter rule in bash description, got %q", description)
|
||||
shell := detectShellRuntime(runtime.GOOS)
|
||||
if shell.Kind == shellKindPowerShell {
|
||||
for _, want := range []string{"powershell", "cwd", "get-childitem", "select-string", "$env:name"} {
|
||||
if !strings.Contains(description, want) {
|
||||
t.Fatalf("expected Windows PowerShell guidance %q in bash description, got %q", want, description)
|
||||
}
|
||||
}
|
||||
} else if !strings.Contains(description, "cmd.exe") || !strings.Contains(description, "cwd") {
|
||||
t.Fatalf("expected Windows cmd.exe fallback guidance in bash description, got %q", description)
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -316,7 +320,7 @@ func TestDetectShellOutputIssueAddsWindowsSyntaxHint(t *testing.T) {
|
||||
|
||||
func TestRegistryBlocksBashWithoutGrant(t *testing.T) {
|
||||
registry := NewRegistry()
|
||||
registry.Register(NewBashTool(t.TempDir()))
|
||||
registry.Register(NewScopedBashTool(t.TempDir(), nil))
|
||||
|
||||
result := registry.Run(context.Background(), "bash", map[string]any{
|
||||
"command": helperCommand("success"),
|
||||
@@ -333,7 +337,7 @@ func TestRegistryBlocksBashWithoutGrant(t *testing.T) {
|
||||
func TestBashToolRunsCommandInWorkspace(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
|
||||
result := NewBashTool(root).Run(context.Background(), map[string]any{
|
||||
result := NewScopedBashTool(root, nil).Run(context.Background(), map[string]any{
|
||||
"command": helperCommand("success"),
|
||||
})
|
||||
|
||||
@@ -379,7 +383,7 @@ func TestBashToolBoundsRunawayOutputCapture(t *testing.T) {
|
||||
}
|
||||
const produced = 500000 // ~5× the 96 KiB budget
|
||||
|
||||
result := NewBashTool(t.TempDir()).Run(context.Background(), map[string]any{
|
||||
result := NewScopedBashTool(t.TempDir(), nil).Run(context.Background(), map[string]any{
|
||||
"command": fmt.Sprintf("yes ABCDEFGH | head -c %d", produced),
|
||||
})
|
||||
|
||||
@@ -414,7 +418,7 @@ func TestBashToolUsesRequestedCwd(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
result := NewBashTool(root).Run(context.Background(), map[string]any{
|
||||
result := NewScopedBashTool(root, nil).Run(context.Background(), map[string]any{
|
||||
"command": helperCommand("pwd"),
|
||||
"cwd": "nested",
|
||||
})
|
||||
@@ -434,7 +438,7 @@ func TestBashToolUsesRequestedCwd(t *testing.T) {
|
||||
func TestBashToolRejectsCwdOutsideWorkspace(t *testing.T) {
|
||||
outside := t.TempDir()
|
||||
|
||||
result := NewBashTool(t.TempDir()).Run(context.Background(), map[string]any{
|
||||
result := NewScopedBashTool(t.TempDir(), nil).Run(context.Background(), map[string]any{
|
||||
"command": helperCommand("success"),
|
||||
"cwd": outside,
|
||||
})
|
||||
@@ -448,20 +452,21 @@ func TestBashToolRejectsCwdOutsideWorkspace(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestBashToolReturnsNonzeroExitAsError(t *testing.T) {
|
||||
result := NewBashTool(t.TempDir()).Run(context.Background(), map[string]any{
|
||||
result := NewScopedBashTool(t.TempDir(), nil).Run(context.Background(), map[string]any{
|
||||
"command": helperCommand("fail"),
|
||||
})
|
||||
|
||||
if result.Status != StatusError {
|
||||
t.Fatalf("expected error status, got %s", result.Status)
|
||||
}
|
||||
for _, want := range []string{"stdout:\nbefore failure", "stderr:\nfailure details", "exit_code: 7"} {
|
||||
wantExitCode := strconv.Itoa(helperFailureExitCode())
|
||||
for _, want := range []string{"stdout:\nbefore failure", "stderr:\nfailure details", "exit_code: " + wantExitCode} {
|
||||
if !strings.Contains(result.Output, want) {
|
||||
t.Fatalf("expected output to contain %q, got %q", want, result.Output)
|
||||
}
|
||||
}
|
||||
if result.Meta["exit_code"] != "7" {
|
||||
t.Fatalf("expected exit_code metadata 7, got %q", result.Meta["exit_code"])
|
||||
if result.Meta["exit_code"] != wantExitCode {
|
||||
t.Fatalf("expected exit_code metadata %s, got %q", wantExitCode, result.Meta["exit_code"])
|
||||
}
|
||||
if result.ExecutionOutcome == nil || result.ExecutionOutcome.State != execution.StateFailed || result.ExecutionOutcome.Kind != execution.OutcomeApplicationFailure {
|
||||
t.Fatalf("execution outcome = %#v, want failed/application_failure", result.ExecutionOutcome)
|
||||
@@ -469,7 +474,7 @@ func TestBashToolReturnsNonzeroExitAsError(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestBashToolTimesOut(t *testing.T) {
|
||||
result := NewBashTool(t.TempDir()).Run(context.Background(), map[string]any{
|
||||
result := NewScopedBashTool(t.TempDir(), nil).Run(context.Background(), map[string]any{
|
||||
"command": helperCommand("sleep"),
|
||||
"timeout_ms": 20,
|
||||
})
|
||||
@@ -508,7 +513,7 @@ func TestBashToolTimeoutKillsBackgroundChildren(t *testing.T) {
|
||||
command := fmt.Sprintf("(sleep 1; touch %s) & wait", shellQuote(sentinel))
|
||||
|
||||
start := time.Now()
|
||||
result := NewBashTool(root).Run(context.Background(), map[string]any{
|
||||
result := NewScopedBashTool(root, nil).Run(context.Background(), map[string]any{
|
||||
"command": command,
|
||||
"timeout_ms": 300,
|
||||
})
|
||||
@@ -533,7 +538,7 @@ func TestBashToolTimeoutKillsBackgroundChildren(t *testing.T) {
|
||||
func TestRegistryRunsWithDegradedUnavailableNativeSandbox(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := NewRegistry()
|
||||
registry.Register(NewBashTool(root))
|
||||
registry.Register(NewScopedBashTool(root, nil))
|
||||
engine := sandbox.NewEngine(sandbox.EngineOptions{
|
||||
WorkspaceRoot: root,
|
||||
Policy: sandbox.DefaultPolicy(),
|
||||
@@ -577,11 +582,17 @@ func TestBashToolRequireEscalatedMsysGuard(t *testing.T) {
|
||||
})
|
||||
}
|
||||
registry := NewRegistry()
|
||||
registry.Register(NewBashTool(root))
|
||||
registry.Register(NewScopedBashTool(root, nil))
|
||||
msysCommand := "cat somefile.txt"
|
||||
if detectShellRuntime(runtime.GOOS).Kind == shellKindPowerShell {
|
||||
// cat is a native Get-Content alias in PowerShell. Use a name that
|
||||
// still resolves to an incompatible Git-for-Windows MSYS executable.
|
||||
msysCommand = "grep pattern somefile.txt"
|
||||
}
|
||||
|
||||
t.Run("default sandboxing still blocks an MSYS-prone command", func(t *testing.T) {
|
||||
result := registry.RunWithOptions(context.Background(), "bash", map[string]any{
|
||||
"command": "cat somefile.txt",
|
||||
"command": msysCommand,
|
||||
}, RunOptions{
|
||||
PermissionGranted: true,
|
||||
Sandbox: newEngine(),
|
||||
@@ -594,7 +605,7 @@ func TestBashToolRequireEscalatedMsysGuard(t *testing.T) {
|
||||
|
||||
t.Run("approved require_escalated bypasses the MSYS guard", func(t *testing.T) {
|
||||
result := registry.RunWithOptions(context.Background(), "bash", map[string]any{
|
||||
"command": "cat somefile.txt",
|
||||
"command": msysCommand,
|
||||
"sandbox_permissions": string(SandboxPermissionsRequireEscalated),
|
||||
}, RunOptions{
|
||||
PermissionGranted: true,
|
||||
@@ -644,12 +655,13 @@ func TestBashToolIgnoresMsysMarkersInCommandArgumentsAfterFailure(t *testing.T)
|
||||
// otherwise unused by the helper but still part of the command line text.
|
||||
command := helperCommand("fail") + ` "fatal error - CreateFileMapping S-1-5-21, Win32 error 5. Terminating. cygheap_user::init"`
|
||||
|
||||
result := NewBashTool(root).Run(context.Background(), map[string]any{
|
||||
result := NewScopedBashTool(root, nil).Run(context.Background(), map[string]any{
|
||||
"command": command,
|
||||
})
|
||||
|
||||
if result.Status != StatusError || result.Meta["exit_code"] != "7" {
|
||||
t.Fatalf("expected the helper's real exit 7 failure, got %s: %#v", result.Status, result)
|
||||
wantExitCode := strconv.Itoa(helperFailureExitCode())
|
||||
if result.Status != StatusError || result.Meta["exit_code"] != wantExitCode {
|
||||
t.Fatalf("expected the helper failure exit %s, got %s: %#v", wantExitCode, result.Status, result)
|
||||
}
|
||||
if result.Meta["shell_issue"] == "windows_msys_sandbox" {
|
||||
t.Fatalf("expected the MSYS marker in the command's own argument text to be ignored, got %#v", result)
|
||||
@@ -665,7 +677,7 @@ func TestBashToolRunsWithDegradedUnavailableNativeSandbox(t *testing.T) {
|
||||
Backend: sandbox.Backend{Name: sandbox.BackendUnavailable, Message: "native sandbox unavailable"},
|
||||
})
|
||||
|
||||
result := NewBashTool(root).(interface {
|
||||
result := NewScopedBashTool(root, nil).(interface {
|
||||
RunWithSandbox(context.Context, map[string]any, *sandbox.Engine) Result
|
||||
}).RunWithSandbox(context.Background(), map[string]any{
|
||||
"command": helperCommand("success"),
|
||||
@@ -725,7 +737,7 @@ func TestBashToolRunsWithHostSandboxBackendWhenAvailable(t *testing.T) {
|
||||
Backend: backend,
|
||||
})
|
||||
|
||||
result := NewBashTool(root).(interface {
|
||||
result := NewScopedBashTool(root, nil).(interface {
|
||||
RunWithSandbox(context.Context, map[string]any, *sandbox.Engine) Result
|
||||
}).RunWithSandbox(context.Background(), map[string]any{
|
||||
"command": "printf sandbox-ok",
|
||||
@@ -745,7 +757,7 @@ func TestBashToolRunsWithHostSandboxBackendWhenAvailable(t *testing.T) {
|
||||
func TestBashToolBlocksInteractiveCommandBeforeExecution(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
|
||||
result := NewBashTool(root).Run(context.Background(), map[string]any{
|
||||
result := NewScopedBashTool(root, nil).Run(context.Background(), map[string]any{
|
||||
"command": "vim main.go",
|
||||
})
|
||||
|
||||
@@ -778,7 +790,7 @@ func TestBashToolBlocksInteractiveCommandThroughSandbox(t *testing.T) {
|
||||
Backend: sandbox.Backend{Name: sandbox.BackendUnavailable, Message: "native sandbox unavailable"},
|
||||
})
|
||||
|
||||
result := NewBashTool(root).(interface {
|
||||
result := NewScopedBashTool(root, nil).(interface {
|
||||
RunWithSandbox(context.Context, map[string]any, *sandbox.Engine) Result
|
||||
}).RunWithSandbox(context.Background(), map[string]any{
|
||||
"command": "less /etc/hosts",
|
||||
@@ -802,7 +814,7 @@ func TestBashToolBlocksInteractiveCommandThroughSandbox(t *testing.T) {
|
||||
func TestBashToolAllowsNonInteractiveCommand(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
|
||||
result := NewBashTool(root).Run(context.Background(), map[string]any{
|
||||
result := NewScopedBashTool(root, nil).Run(context.Background(), map[string]any{
|
||||
"command": helperCommand("success"),
|
||||
})
|
||||
|
||||
@@ -835,7 +847,7 @@ func TestBashToolPreservesEmbeddedQuotesOnWindows(t *testing.T) {
|
||||
// showed exactly where the corruption happened.
|
||||
commandText := executable + ` --zero-bash-helper echo-arg "hello / world"`
|
||||
|
||||
result := NewBashTool(root).Run(context.Background(), map[string]any{
|
||||
result := NewScopedBashTool(root, nil).Run(context.Background(), map[string]any{
|
||||
"command": commandText,
|
||||
})
|
||||
|
||||
@@ -860,9 +872,12 @@ func TestBashToolRunsCommandLineForLoopSyntax(t *testing.T) {
|
||||
if runtime.GOOS != "windows" {
|
||||
t.Skip("cmd.exe command-line vs batch-file parsing is Windows-specific")
|
||||
}
|
||||
if detectShellRuntime(runtime.GOOS).Kind != shellKindCmd {
|
||||
t.Skip("cmd.exe fallback is not selected")
|
||||
}
|
||||
root := t.TempDir()
|
||||
|
||||
result := NewBashTool(root).Run(context.Background(), map[string]any{
|
||||
result := NewScopedBashTool(root, nil).Run(context.Background(), map[string]any{
|
||||
"command": "for %i in (1 2 3) do echo %i",
|
||||
})
|
||||
|
||||
@@ -881,6 +896,10 @@ func helperCommand(name string) string {
|
||||
return executable + " --zero-bash-helper " + name
|
||||
}
|
||||
|
||||
func helperFailureExitCode() int {
|
||||
return 7
|
||||
}
|
||||
|
||||
func shellQuote(value string) string {
|
||||
if runtime.GOOS == "windows" {
|
||||
return value
|
||||
|
||||
@@ -19,9 +19,9 @@ func BuiltinCatalog(workspaceRoot string) []Tool {
|
||||
workspaceRoot = "."
|
||||
}
|
||||
var all []Tool
|
||||
all = append(all, CoreReadOnlyTools(workspaceRoot)...)
|
||||
all = append(all, CoreWriteTools(workspaceRoot)...)
|
||||
all = append(all, CoreShellTools(workspaceRoot)...)
|
||||
all = append(all, CoreReadOnlyToolsScoped(workspaceRoot, nil)...)
|
||||
all = append(all, CoreWriteToolsScoped(workspaceRoot, nil)...)
|
||||
all = append(all, CoreShellToolsScoped(workspaceRoot, nil)...)
|
||||
// Network tools: always include web_fetch; always include web_search for
|
||||
// classification even when no backend is configured at runtime (registration
|
||||
// remains conditional in CoreNetworkTools).
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
@@ -19,107 +18,6 @@ var (
|
||||
collapseWhitespace = regexp.MustCompile(`\s+`)
|
||||
)
|
||||
|
||||
// normalizeDescriptionLine trims a line and unwraps a leading markdown heading.
|
||||
func normalizeDescriptionLine(line string) string {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if m := headingPrefix.FindStringSubmatch(trimmed); m != nil {
|
||||
return strings.TrimSpace(m[1])
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
func isGenericDescriptionHeading(line string) bool {
|
||||
return genericHeading.MatchString(line)
|
||||
}
|
||||
|
||||
// truncateDescription clips desc to at most max runes, preferring a word
|
||||
// boundary and appending a single-rune ellipsis when it had to cut.
|
||||
func truncateDescription(desc string, max int) string {
|
||||
runes := []rune(desc)
|
||||
if max <= 0 || len(runes) <= max {
|
||||
return desc
|
||||
}
|
||||
cut := string(runes[:max-1])
|
||||
if idx := strings.LastIndexByte(cut, ' '); idx > 0 {
|
||||
cut = cut[:idx]
|
||||
}
|
||||
return strings.TrimRight(cut, " ") + "…"
|
||||
}
|
||||
|
||||
// shortenDescription reduces desc to a single meaningful line, collapses
|
||||
// whitespace, and truncates to at most max runes with an ellipsis.
|
||||
func shortenDescription(desc string, max int) string {
|
||||
if desc == "" {
|
||||
return ""
|
||||
}
|
||||
if max <= 0 {
|
||||
max = defaultShortenMax
|
||||
}
|
||||
var lines []string
|
||||
for _, raw := range strings.Split(desc, "\n") {
|
||||
if line := normalizeDescriptionLine(raw); line != "" {
|
||||
lines = append(lines, collapseWhitespace.ReplaceAllString(line, " "))
|
||||
}
|
||||
}
|
||||
if len(lines) == 0 {
|
||||
return ""
|
||||
}
|
||||
meaningful := lines[0]
|
||||
if isGenericDescriptionHeading(meaningful) && len(lines) > 1 {
|
||||
meaningful = meaningful + " — " + lines[1]
|
||||
}
|
||||
return truncateDescription(meaningful, max)
|
||||
}
|
||||
|
||||
// formatInputSchemaHint renders a one-line summary of a tool's input schema,
|
||||
// e.g. "inputs (* required): a (string)*, b (number); +N more". Property names
|
||||
// are sorted for deterministic output (Schema.Properties is a map). Returns
|
||||
// "(none)" when the schema declares no properties. At most maxSchemaHintParams
|
||||
// params are shown; the rest are summarized as "; +N more".
|
||||
func formatInputSchemaHint(schema Schema) string {
|
||||
if len(schema.Properties) == 0 {
|
||||
return "(none)"
|
||||
}
|
||||
|
||||
names := make([]string, 0, len(schema.Properties))
|
||||
for name := range schema.Properties {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
|
||||
required := make(map[string]bool, len(schema.Required))
|
||||
for _, name := range schema.Required {
|
||||
required[name] = true
|
||||
}
|
||||
|
||||
shown := names
|
||||
if len(shown) > maxSchemaHintParams {
|
||||
shown = shown[:maxSchemaHintParams]
|
||||
}
|
||||
|
||||
parts := make([]string, 0, len(shown))
|
||||
for _, name := range shown {
|
||||
prop := schema.Properties[name]
|
||||
marker := ""
|
||||
if required[name] {
|
||||
marker = "*"
|
||||
}
|
||||
typePart := ""
|
||||
if t := strings.TrimSpace(prop.Type); t != "" {
|
||||
typePart = " (" + t + ")"
|
||||
}
|
||||
parts = append(parts, name+typePart+marker)
|
||||
}
|
||||
|
||||
more := ""
|
||||
if len(names) > maxSchemaHintParams {
|
||||
more = fmt.Sprintf("; +%d more", len(names)-maxSchemaHintParams)
|
||||
}
|
||||
|
||||
hint := "inputs (* required): " + strings.Join(parts, ", ") + more
|
||||
return shortenDescription(hint, maxSchemaHintLen)
|
||||
}
|
||||
|
||||
// mcpToolNamePrefix mirrors the prefix used by mcp.registryToolName.
|
||||
const mcpToolNamePrefix = "mcp_"
|
||||
|
||||
@@ -139,22 +37,6 @@ func mcpServerFromToolName(name string) string {
|
||||
return rest[:sep]
|
||||
}
|
||||
|
||||
// formatDeferredToolLine renders a single compact advertisement line for a
|
||||
// deferred tool: "name: <short-desc> | server: <X> | <input-hint>". The
|
||||
// "server: <X>" segment is omitted when server is empty (non-MCP tools).
|
||||
func formatDeferredToolLine(name, description, server string, schema Schema) string {
|
||||
desc := shortenDescription(description, defaultShortenMax)
|
||||
if desc == "" {
|
||||
desc = "No description provided"
|
||||
}
|
||||
parts := []string{name + ": " + desc}
|
||||
if server != "" {
|
||||
parts = append(parts, "server: "+server)
|
||||
}
|
||||
parts = append(parts, formatInputSchemaHint(schema))
|
||||
return strings.Join(parts, " | ")
|
||||
}
|
||||
|
||||
// mcpServerNamed is an optional interface a deferred MCP tool implements to
|
||||
// report its true (un-sanitized-token) server name for discovery labels. When
|
||||
// a tool provides it, DeferredLine prefers it over the name-derived token, which
|
||||
@@ -165,22 +47,6 @@ type mcpServerNamed interface {
|
||||
MCPServerName() string
|
||||
}
|
||||
|
||||
// DeferredLine renders the compact advertisement line for a single deferred
|
||||
// tool, deriving the MCP server label from the tool's reported server name when
|
||||
// available, falling back to the token parsed from the tool's name. It is the
|
||||
// exported entry point the agent loop uses to build compact deferred metadata,
|
||||
// so callers in other packages never touch the
|
||||
// unexported formatters.
|
||||
func DeferredLine(t Tool) string {
|
||||
server := mcpServerFromToolName(t.Name())
|
||||
if named, ok := t.(mcpServerNamed); ok {
|
||||
if reported := strings.TrimSpace(named.MCPServerName()); reported != "" {
|
||||
server = reported
|
||||
}
|
||||
}
|
||||
return formatDeferredToolLine(t.Name(), t.Description(), server, t.Parameters())
|
||||
}
|
||||
|
||||
// DeferredSource reports the compact source label used in tool_search's dynamic
|
||||
// description. MCP tools use their configured server name; other deferred tools
|
||||
// fall back to the first name segment so families such as swarm_* are grouped.
|
||||
|
||||
@@ -14,10 +14,6 @@ type editFileTool struct {
|
||||
scope PathScope
|
||||
}
|
||||
|
||||
func NewEditFileTool(workspaceRoot string) Tool {
|
||||
return NewScopedEditFileTool(workspaceRoot, nil)
|
||||
}
|
||||
|
||||
func NewScopedEditFileTool(workspaceRoot string, scope PathScope) Tool {
|
||||
return editFileTool{
|
||||
baseTool: baseTool{
|
||||
|
||||
@@ -17,7 +17,7 @@ func runEdit(t *testing.T, dir, initial string, args map[string]any) (Result, st
|
||||
if args["path"] == nil {
|
||||
args["path"] = "target.txt"
|
||||
}
|
||||
result := NewEditFileTool(dir).Run(context.Background(), args)
|
||||
result := NewScopedEditFileTool(dir, nil).Run(context.Background(), args)
|
||||
after, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
Loaded 100 of 165 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user