orch-code 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/LICENSE +21 -0
- package/README.md +624 -0
- package/cmd/apply.go +111 -0
- package/cmd/auth.go +393 -0
- package/cmd/auth_test.go +100 -0
- package/cmd/diff.go +57 -0
- package/cmd/doctor.go +149 -0
- package/cmd/explain.go +192 -0
- package/cmd/explain_test.go +62 -0
- package/cmd/init.go +100 -0
- package/cmd/interactive.go +1372 -0
- package/cmd/interactive_input.go +45 -0
- package/cmd/interactive_input_test.go +55 -0
- package/cmd/logs.go +72 -0
- package/cmd/model.go +84 -0
- package/cmd/plan.go +149 -0
- package/cmd/provider.go +189 -0
- package/cmd/provider_model_doctor_test.go +91 -0
- package/cmd/root.go +67 -0
- package/cmd/run.go +123 -0
- package/cmd/run_engine.go +208 -0
- package/cmd/run_engine_test.go +30 -0
- package/cmd/session.go +589 -0
- package/cmd/session_helpers.go +54 -0
- package/cmd/session_integration_test.go +30 -0
- package/cmd/session_list_current_test.go +87 -0
- package/cmd/session_messages_test.go +163 -0
- package/cmd/session_runs_test.go +68 -0
- package/cmd/sprint1_integration_test.go +119 -0
- package/cmd/stats.go +173 -0
- package/cmd/stats_test.go +71 -0
- package/cmd/version.go +4 -0
- package/go.mod +45 -0
- package/go.sum +108 -0
- package/internal/agents/agent.go +31 -0
- package/internal/agents/coder.go +167 -0
- package/internal/agents/planner.go +155 -0
- package/internal/agents/reviewer.go +118 -0
- package/internal/agents/runtime.go +25 -0
- package/internal/agents/runtime_test.go +77 -0
- package/internal/auth/account.go +78 -0
- package/internal/auth/oauth.go +523 -0
- package/internal/auth/store.go +287 -0
- package/internal/confidence/policy.go +174 -0
- package/internal/confidence/policy_test.go +71 -0
- package/internal/confidence/scorer.go +253 -0
- package/internal/confidence/scorer_test.go +83 -0
- package/internal/config/config.go +331 -0
- package/internal/config/config_defaults_test.go +138 -0
- package/internal/execution/contract_builder.go +160 -0
- package/internal/execution/contract_builder_test.go +68 -0
- package/internal/execution/plan_compliance.go +161 -0
- package/internal/execution/plan_compliance_test.go +71 -0
- package/internal/execution/retry_directive.go +132 -0
- package/internal/execution/scope_guard.go +69 -0
- package/internal/logger/logger.go +120 -0
- package/internal/models/contracts_test.go +100 -0
- package/internal/models/models.go +269 -0
- package/internal/orchestrator/orchestrator.go +701 -0
- package/internal/orchestrator/orchestrator_retry_test.go +135 -0
- package/internal/orchestrator/review_engine_test.go +50 -0
- package/internal/orchestrator/state.go +42 -0
- package/internal/orchestrator/test_classifier_test.go +68 -0
- package/internal/patch/applier.go +131 -0
- package/internal/patch/applier_test.go +25 -0
- package/internal/patch/parser.go +89 -0
- package/internal/patch/patch.go +60 -0
- package/internal/patch/summary.go +30 -0
- package/internal/patch/validator.go +104 -0
- package/internal/planning/normalizer.go +416 -0
- package/internal/planning/normalizer_test.go +64 -0
- package/internal/providers/errors.go +35 -0
- package/internal/providers/openai/client.go +498 -0
- package/internal/providers/openai/client_test.go +187 -0
- package/internal/providers/provider.go +47 -0
- package/internal/providers/registry.go +32 -0
- package/internal/providers/registry_test.go +57 -0
- package/internal/providers/router.go +52 -0
- package/internal/providers/state.go +114 -0
- package/internal/providers/state_test.go +64 -0
- package/internal/repo/analyzer.go +188 -0
- package/internal/repo/context.go +83 -0
- package/internal/review/engine.go +267 -0
- package/internal/review/engine_test.go +103 -0
- package/internal/runstore/store.go +137 -0
- package/internal/runstore/store_test.go +59 -0
- package/internal/runtime/lock.go +150 -0
- package/internal/runtime/lock_test.go +57 -0
- package/internal/session/compaction.go +260 -0
- package/internal/session/compaction_test.go +36 -0
- package/internal/session/service.go +117 -0
- package/internal/session/service_test.go +113 -0
- package/internal/storage/storage.go +1498 -0
- package/internal/storage/storage_test.go +413 -0
- package/internal/testing/classifier.go +80 -0
- package/internal/testing/classifier_test.go +36 -0
- package/internal/tools/command.go +160 -0
- package/internal/tools/command_test.go +56 -0
- package/internal/tools/file.go +111 -0
- package/internal/tools/git.go +77 -0
- package/internal/tools/invalid_params_test.go +36 -0
- package/internal/tools/policy.go +98 -0
- package/internal/tools/policy_test.go +36 -0
- package/internal/tools/registry_test.go +52 -0
- package/internal/tools/result.go +30 -0
- package/internal/tools/search.go +86 -0
- package/internal/tools/tool.go +94 -0
- package/main.go +9 -0
- package/npm/orch.js +25 -0
- package/package.json +41 -0
- package/scripts/changelog.js +20 -0
- package/scripts/check-release-version.js +21 -0
- package/scripts/lib/release-utils.js +223 -0
- package/scripts/postinstall.js +157 -0
- package/scripts/release.js +52 -0
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
package tools
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"strings"
|
|
5
|
+
"testing"
|
|
6
|
+
)
|
|
7
|
+
|
|
8
|
+
func TestRunCommandBlocksRiskyCommandWithoutApproval(t *testing.T) {
|
|
9
|
+
tool := NewRunCommandTool(t.TempDir())
|
|
10
|
+
|
|
11
|
+
result, err := tool.Execute(map[string]string{"command": "rm -rf /tmp/orch-test"})
|
|
12
|
+
if err != nil {
|
|
13
|
+
t.Fatalf("unexpected execute error: %v", err)
|
|
14
|
+
}
|
|
15
|
+
if result.Success {
|
|
16
|
+
t.Fatalf("expected risky command to be blocked")
|
|
17
|
+
}
|
|
18
|
+
if result.ErrorCode != ErrCodePolicyBlocked {
|
|
19
|
+
t.Fatalf("unexpected error code: %s", result.ErrorCode)
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
func TestRunCommandTimesOut(t *testing.T) {
|
|
24
|
+
tool := NewRunCommandTool(t.TempDir())
|
|
25
|
+
|
|
26
|
+
result, err := tool.Execute(map[string]string{
|
|
27
|
+
"command": "sleep 2",
|
|
28
|
+
"timeout_seconds": "1",
|
|
29
|
+
})
|
|
30
|
+
if err != nil {
|
|
31
|
+
t.Fatalf("unexpected execute error: %v", err)
|
|
32
|
+
}
|
|
33
|
+
if result.Success {
|
|
34
|
+
t.Fatalf("expected timeout failure")
|
|
35
|
+
}
|
|
36
|
+
if result.ErrorCode != ErrCodeTimeout {
|
|
37
|
+
t.Fatalf("unexpected timeout code: %s", result.ErrorCode)
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
func TestRunCommandTruncatesLargeOutputToFile(t *testing.T) {
|
|
42
|
+
tool := NewRunCommandTool(t.TempDir())
|
|
43
|
+
|
|
44
|
+
result, err := tool.Execute(map[string]string{
|
|
45
|
+
"command": "seq 1 25000",
|
|
46
|
+
})
|
|
47
|
+
if err != nil {
|
|
48
|
+
t.Fatalf("unexpected execute error: %v", err)
|
|
49
|
+
}
|
|
50
|
+
if !result.Success {
|
|
51
|
+
t.Fatalf("expected command success, got error: %s", result.Error)
|
|
52
|
+
}
|
|
53
|
+
if result.Metadata == nil || strings.TrimSpace(result.Metadata["output_file"]) == "" {
|
|
54
|
+
t.Fatalf("expected truncated output metadata with output_file")
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
package tools
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"fmt"
|
|
5
|
+
"os"
|
|
6
|
+
"path/filepath"
|
|
7
|
+
"strings"
|
|
8
|
+
|
|
9
|
+
"github.com/furkanbeydemir/orch/internal/models"
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
type ReadFileTool struct {
|
|
13
|
+
repoRoot string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
func NewReadFileTool(repoRoot string) *ReadFileTool {
|
|
17
|
+
return &ReadFileTool{repoRoot: repoRoot}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
func (t *ReadFileTool) Name() string { return "read_file" }
|
|
21
|
+
|
|
22
|
+
func (t *ReadFileTool) Description() string { return "Reads contents of the specified file" }
|
|
23
|
+
|
|
24
|
+
func (t *ReadFileTool) Execute(params map[string]string) (*models.ToolResult, error) {
|
|
25
|
+
path, ok := params["path"]
|
|
26
|
+
if !ok {
|
|
27
|
+
return Failure("read_file", ErrCodeInvalidParams, "read_file: 'path' parameter is required", ""), nil
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
fullPath := filepath.Join(t.repoRoot, path)
|
|
31
|
+
data, err := os.ReadFile(fullPath)
|
|
32
|
+
if err != nil {
|
|
33
|
+
return Failure("read_file", ErrCodeExecution, err.Error(), ""), nil
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return Success("read_file", string(data)), nil
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
type WriteFileTool struct {
|
|
40
|
+
repoRoot string
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
func NewWriteFileTool(repoRoot string) *WriteFileTool {
|
|
44
|
+
return &WriteFileTool{repoRoot: repoRoot}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
func (t *WriteFileTool) Name() string { return "write_file" }
|
|
48
|
+
|
|
49
|
+
func (t *WriteFileTool) Description() string { return "Writes content to the specified file" }
|
|
50
|
+
|
|
51
|
+
func (t *WriteFileTool) Execute(params map[string]string) (*models.ToolResult, error) {
|
|
52
|
+
path, ok := params["path"]
|
|
53
|
+
if !ok {
|
|
54
|
+
return Failure("write_file", ErrCodeInvalidParams, "write_file: 'path' parameter is required", ""), nil
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
content, ok := params["content"]
|
|
58
|
+
if !ok {
|
|
59
|
+
return Failure("write_file", ErrCodeInvalidParams, "write_file: 'content' parameter is required", ""), nil
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
fullPath := filepath.Join(t.repoRoot, path)
|
|
63
|
+
|
|
64
|
+
dir := filepath.Dir(fullPath)
|
|
65
|
+
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
66
|
+
return Failure("write_file", ErrCodeExecution, err.Error(), ""), nil
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if err := os.WriteFile(fullPath, []byte(content), 0o644); err != nil {
|
|
70
|
+
return Failure("write_file", ErrCodeExecution, err.Error(), ""), nil
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return Success("write_file", fmt.Sprintf("File written: %s", path)), nil
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
type ListFilesTool struct {
|
|
77
|
+
repoRoot string
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
func NewListFilesTool(repoRoot string) *ListFilesTool {
|
|
81
|
+
return &ListFilesTool{repoRoot: repoRoot}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
func (t *ListFilesTool) Name() string { return "list_files" }
|
|
85
|
+
|
|
86
|
+
func (t *ListFilesTool) Description() string { return "Lists files in the specified directory" }
|
|
87
|
+
|
|
88
|
+
// Params: "path" optional directory path (default: ".").
|
|
89
|
+
func (t *ListFilesTool) Execute(params map[string]string) (*models.ToolResult, error) {
|
|
90
|
+
path := params["path"]
|
|
91
|
+
if path == "" {
|
|
92
|
+
path = "."
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
fullPath := filepath.Join(t.repoRoot, path)
|
|
96
|
+
entries, err := os.ReadDir(fullPath)
|
|
97
|
+
if err != nil {
|
|
98
|
+
return Failure("list_files", ErrCodeExecution, err.Error(), ""), nil
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
var files []string
|
|
102
|
+
for _, entry := range entries {
|
|
103
|
+
prefix := "F"
|
|
104
|
+
if entry.IsDir() {
|
|
105
|
+
prefix = "D"
|
|
106
|
+
}
|
|
107
|
+
files = append(files, fmt.Sprintf("[%s] %s", prefix, entry.Name()))
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return Success("list_files", strings.Join(files, "\n")), nil
|
|
111
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// - ApplyPatch: patch application
|
|
2
|
+
package tools
|
|
3
|
+
|
|
4
|
+
import (
|
|
5
|
+
"os/exec"
|
|
6
|
+
"strings"
|
|
7
|
+
|
|
8
|
+
"github.com/furkanbeydemir/orch/internal/models"
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
type GitDiffTool struct {
|
|
12
|
+
repoRoot string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
func NewGitDiffTool(repoRoot string) *GitDiffTool {
|
|
16
|
+
return &GitDiffTool{repoRoot: repoRoot}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
func (t *GitDiffTool) Name() string { return "git_diff" }
|
|
20
|
+
|
|
21
|
+
func (t *GitDiffTool) Description() string { return "Produces git diff output" }
|
|
22
|
+
|
|
23
|
+
func (t *GitDiffTool) Execute(params map[string]string) (*models.ToolResult, error) {
|
|
24
|
+
args := []string{"diff"}
|
|
25
|
+
if path, ok := params["path"]; ok && path != "" {
|
|
26
|
+
args = append(args, "--", path)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
cmd := exec.Command("git", args...)
|
|
30
|
+
cmd.Dir = t.repoRoot
|
|
31
|
+
|
|
32
|
+
output, err := cmd.CombinedOutput()
|
|
33
|
+
if err != nil {
|
|
34
|
+
return Failure("git_diff", ErrCodeExecution, err.Error(), string(output)), nil
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return Success("git_diff", string(output)), nil
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
type ApplyPatchTool struct {
|
|
41
|
+
repoRoot string
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
func NewApplyPatchTool(repoRoot string) *ApplyPatchTool {
|
|
45
|
+
return &ApplyPatchTool{repoRoot: repoRoot}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
func (t *ApplyPatchTool) Name() string { return "apply_patch" }
|
|
49
|
+
|
|
50
|
+
func (t *ApplyPatchTool) Description() string { return "Applies a patch with git" }
|
|
51
|
+
|
|
52
|
+
// Execute applies a patch using git apply.
|
|
53
|
+
func (t *ApplyPatchTool) Execute(params map[string]string) (*models.ToolResult, error) {
|
|
54
|
+
patchContent, ok := params["patch"]
|
|
55
|
+
if !ok {
|
|
56
|
+
return Failure("apply_patch", ErrCodeInvalidParams, "apply_patch: 'patch' parameter is required", ""), nil
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
args := []string{"apply"}
|
|
60
|
+
|
|
61
|
+
if dryRun, exists := params["dry_run"]; exists && dryRun == "true" {
|
|
62
|
+
args = append(args, "--check")
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
args = append(args, "-")
|
|
66
|
+
|
|
67
|
+
cmd := exec.Command("git", args...)
|
|
68
|
+
cmd.Dir = t.repoRoot
|
|
69
|
+
cmd.Stdin = strings.NewReader(patchContent)
|
|
70
|
+
|
|
71
|
+
output, err := cmd.CombinedOutput()
|
|
72
|
+
if err != nil {
|
|
73
|
+
return Failure("apply_patch", ErrCodeExecution, err.Error(), string(output)), nil
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return Success("apply_patch", "Patch applied successfully"), nil
|
|
77
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
package tools
|
|
2
|
+
|
|
3
|
+
import "testing"
|
|
4
|
+
|
|
5
|
+
func TestToolsReturnInvalidParamsContract(t *testing.T) {
|
|
6
|
+
repoRoot := t.TempDir()
|
|
7
|
+
|
|
8
|
+
tests := []struct {
|
|
9
|
+
name string
|
|
10
|
+
tool Tool
|
|
11
|
+
}{
|
|
12
|
+
{name: "read_file", tool: NewReadFileTool(repoRoot)},
|
|
13
|
+
{name: "write_file", tool: NewWriteFileTool(repoRoot)},
|
|
14
|
+
{name: "search_code", tool: NewSearchCodeTool(repoRoot)},
|
|
15
|
+
{name: "run_command", tool: NewRunCommandTool(repoRoot)},
|
|
16
|
+
{name: "apply_patch", tool: NewApplyPatchTool(repoRoot)},
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
for _, tc := range tests {
|
|
20
|
+
t.Run(tc.name, func(t *testing.T) {
|
|
21
|
+
result, err := tc.tool.Execute(map[string]string{})
|
|
22
|
+
if err != nil {
|
|
23
|
+
t.Fatalf("unexpected execute error: %v", err)
|
|
24
|
+
}
|
|
25
|
+
if result == nil {
|
|
26
|
+
t.Fatalf("expected tool result")
|
|
27
|
+
}
|
|
28
|
+
if result.Success {
|
|
29
|
+
t.Fatalf("expected failure result for invalid params")
|
|
30
|
+
}
|
|
31
|
+
if result.ErrorCode != ErrCodeInvalidParams {
|
|
32
|
+
t.Fatalf("unexpected error code: %s", result.ErrorCode)
|
|
33
|
+
}
|
|
34
|
+
})
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
package tools
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"fmt"
|
|
5
|
+
"strings"
|
|
6
|
+
|
|
7
|
+
"github.com/furkanbeydemir/orch/internal/models"
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
const (
|
|
11
|
+
ModeRun = "run"
|
|
12
|
+
ModePlan = "plan"
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
type Policy struct {
|
|
16
|
+
Mode string
|
|
17
|
+
RequireDestructiveApproval bool
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
type policyDecision struct {
|
|
21
|
+
allowed bool
|
|
22
|
+
reason string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
func (p Policy) Decide(toolName string, params map[string]string) policyDecision {
|
|
26
|
+
if p.Mode == "" {
|
|
27
|
+
p.Mode = ModeRun
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if p.Mode == ModePlan {
|
|
31
|
+
switch toolName {
|
|
32
|
+
case "write_file", "apply_patch", "run_command":
|
|
33
|
+
return policyDecision{
|
|
34
|
+
allowed: false,
|
|
35
|
+
reason: fmt.Sprintf("%s blocked in plan mode (read-only)", toolName),
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if p.RequireDestructiveApproval && isDestructiveTool(toolName) {
|
|
41
|
+
if strings.TrimSpace(params["approved"]) != "true" {
|
|
42
|
+
return policyDecision{
|
|
43
|
+
allowed: false,
|
|
44
|
+
reason: fmt.Sprintf("%s requires explicit approval (set approved=true)", toolName),
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return policyDecision{allowed: true}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
func isDestructiveTool(toolName string) bool {
|
|
53
|
+
switch toolName {
|
|
54
|
+
case "write_file", "apply_patch", "run_command":
|
|
55
|
+
return true
|
|
56
|
+
default:
|
|
57
|
+
return false
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
type guardedTool struct {
|
|
62
|
+
inner Tool
|
|
63
|
+
policy Policy
|
|
64
|
+
logf func(string)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
func (t *guardedTool) Name() string {
|
|
68
|
+
return t.inner.Name()
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
func (t *guardedTool) Description() string {
|
|
72
|
+
return t.inner.Description()
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
func (t *guardedTool) Execute(params map[string]string) (*models.ToolResult, error) {
|
|
76
|
+
decision := t.policy.Decide(t.inner.Name(), params)
|
|
77
|
+
t.logDecision(t.inner.Name(), decision)
|
|
78
|
+
if !decision.allowed {
|
|
79
|
+
return Failure(t.inner.Name(), ErrCodePolicyBlocked, decision.reason, ""), nil
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return t.inner.Execute(params)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
func (t *guardedTool) logDecision(toolName string, decision policyDecision) {
|
|
86
|
+
if t.logf == nil {
|
|
87
|
+
return
|
|
88
|
+
}
|
|
89
|
+
result := "allow"
|
|
90
|
+
if !decision.allowed {
|
|
91
|
+
result = "deny"
|
|
92
|
+
}
|
|
93
|
+
message := fmt.Sprintf("policy decision tool=%s mode=%s result=%s", toolName, t.policy.Mode, result)
|
|
94
|
+
if decision.reason != "" {
|
|
95
|
+
message = fmt.Sprintf("%s reason=%s", message, decision.reason)
|
|
96
|
+
}
|
|
97
|
+
t.logf(message)
|
|
98
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
package tools
|
|
2
|
+
|
|
3
|
+
import "testing"
|
|
4
|
+
|
|
5
|
+
func TestPolicyPlanModeBlocksDestructiveTools(t *testing.T) {
|
|
6
|
+
policy := Policy{Mode: ModePlan}
|
|
7
|
+
|
|
8
|
+
decision := policy.Decide("write_file", map[string]string{"path": "a.txt"})
|
|
9
|
+
if decision.allowed {
|
|
10
|
+
t.Fatalf("expected write_file to be blocked in plan mode")
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
decision = policy.Decide("apply_patch", map[string]string{"patch": "diff --git a/a b/a"})
|
|
14
|
+
if decision.allowed {
|
|
15
|
+
t.Fatalf("expected apply_patch to be blocked in plan mode")
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
decision = policy.Decide("run_command", map[string]string{"command": "rm -rf /tmp/x"})
|
|
19
|
+
if decision.allowed {
|
|
20
|
+
t.Fatalf("expected run_command to be blocked in plan mode")
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
func TestPolicyRequiresApprovalForDestructiveTools(t *testing.T) {
|
|
25
|
+
policy := Policy{Mode: ModeRun, RequireDestructiveApproval: true}
|
|
26
|
+
|
|
27
|
+
decision := policy.Decide("write_file", map[string]string{"path": "a.txt"})
|
|
28
|
+
if decision.allowed {
|
|
29
|
+
t.Fatalf("expected write_file to require approval")
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
decision = policy.Decide("write_file", map[string]string{"path": "a.txt", "approved": "true"})
|
|
33
|
+
if !decision.allowed {
|
|
34
|
+
t.Fatalf("expected write_file to be allowed with approval")
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
package tools
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"errors"
|
|
5
|
+
"testing"
|
|
6
|
+
|
|
7
|
+
"github.com/furkanbeydemir/orch/internal/models"
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
func TestRegistryExecuteReturnsStructuredNotFound(t *testing.T) {
|
|
11
|
+
reg := NewRegistry()
|
|
12
|
+
|
|
13
|
+
result, err := reg.Execute("missing_tool", map[string]string{})
|
|
14
|
+
if err != nil {
|
|
15
|
+
t.Fatalf("unexpected error: %v", err)
|
|
16
|
+
}
|
|
17
|
+
if result == nil {
|
|
18
|
+
t.Fatalf("expected result")
|
|
19
|
+
}
|
|
20
|
+
if result.Success {
|
|
21
|
+
t.Fatalf("expected failure result")
|
|
22
|
+
}
|
|
23
|
+
if result.ErrorCode != ErrCodeToolNotFound {
|
|
24
|
+
t.Fatalf("unexpected error code: %s", result.ErrorCode)
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
func TestRegistryExecuteWrapsToolErrors(t *testing.T) {
|
|
29
|
+
reg := NewRegistry()
|
|
30
|
+
reg.Register(&failingTool{})
|
|
31
|
+
|
|
32
|
+
result, err := reg.Execute("failing_tool", map[string]string{})
|
|
33
|
+
if err != nil {
|
|
34
|
+
t.Fatalf("unexpected error: %v", err)
|
|
35
|
+
}
|
|
36
|
+
if result.Success {
|
|
37
|
+
t.Fatalf("expected failure result")
|
|
38
|
+
}
|
|
39
|
+
if result.ErrorCode != ErrCodeExecution {
|
|
40
|
+
t.Fatalf("unexpected error code: %s", result.ErrorCode)
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
type failingTool struct{}
|
|
45
|
+
|
|
46
|
+
func (f *failingTool) Name() string { return "failing_tool" }
|
|
47
|
+
|
|
48
|
+
func (f *failingTool) Description() string { return "fails intentionally" }
|
|
49
|
+
|
|
50
|
+
func (f *failingTool) Execute(params map[string]string) (*models.ToolResult, error) {
|
|
51
|
+
return nil, errors.New("boom")
|
|
52
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
package tools
|
|
2
|
+
|
|
3
|
+
import "github.com/furkanbeydemir/orch/internal/models"
|
|
4
|
+
|
|
5
|
+
const (
|
|
6
|
+
ErrCodeInvalidParams = "invalid_params"
|
|
7
|
+
ErrCodeExecution = "execution_error"
|
|
8
|
+
ErrCodePolicyBlocked = "policy_blocked"
|
|
9
|
+
ErrCodeTimeout = "timeout"
|
|
10
|
+
ErrCodeOutputTrunc = "output_truncated"
|
|
11
|
+
ErrCodeToolNotFound = "tool_not_found"
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
func Success(toolName, output string) *models.ToolResult {
|
|
15
|
+
return &models.ToolResult{
|
|
16
|
+
ToolName: toolName,
|
|
17
|
+
Success: true,
|
|
18
|
+
Output: output,
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
func Failure(toolName, code, message, output string) *models.ToolResult {
|
|
23
|
+
return &models.ToolResult{
|
|
24
|
+
ToolName: toolName,
|
|
25
|
+
Success: false,
|
|
26
|
+
Output: output,
|
|
27
|
+
Error: message,
|
|
28
|
+
ErrorCode: code,
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
package tools
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"bufio"
|
|
5
|
+
"fmt"
|
|
6
|
+
"os"
|
|
7
|
+
"path/filepath"
|
|
8
|
+
"strings"
|
|
9
|
+
|
|
10
|
+
"github.com/furkanbeydemir/orch/internal/models"
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
type SearchCodeTool struct {
|
|
14
|
+
repoRoot string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
func NewSearchCodeTool(repoRoot string) *SearchCodeTool {
|
|
18
|
+
return &SearchCodeTool{repoRoot: repoRoot}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
func (t *SearchCodeTool) Name() string { return "search_code" }
|
|
22
|
+
|
|
23
|
+
func (t *SearchCodeTool) Description() string {
|
|
24
|
+
return "Searches text/patterns in repository"
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Execute searches text in repository files.
|
|
28
|
+
// Params: "query" text to search, "path" optional search root.
|
|
29
|
+
func (t *SearchCodeTool) Execute(params map[string]string) (*models.ToolResult, error) {
|
|
30
|
+
query, ok := params["query"]
|
|
31
|
+
if !ok {
|
|
32
|
+
return Failure("search_code", ErrCodeInvalidParams, "search_code: 'query' parameter is required", ""), nil
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
searchPath := params["path"]
|
|
36
|
+
if searchPath == "" {
|
|
37
|
+
searchPath = "."
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
fullPath := filepath.Join(t.repoRoot, searchPath)
|
|
41
|
+
var results []string
|
|
42
|
+
|
|
43
|
+
err := filepath.Walk(fullPath, func(path string, info os.FileInfo, err error) error {
|
|
44
|
+
if err != nil {
|
|
45
|
+
return nil
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if info.IsDir() {
|
|
49
|
+
name := info.Name()
|
|
50
|
+
if strings.HasPrefix(name, ".") || name == "node_modules" || name == "vendor" {
|
|
51
|
+
return filepath.SkipDir
|
|
52
|
+
}
|
|
53
|
+
return nil
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
file, err := os.Open(path)
|
|
57
|
+
if err != nil {
|
|
58
|
+
return nil
|
|
59
|
+
}
|
|
60
|
+
defer file.Close()
|
|
61
|
+
|
|
62
|
+
relPath, _ := filepath.Rel(t.repoRoot, path)
|
|
63
|
+
ext := strings.ToLower(filepath.Ext(path))
|
|
64
|
+
if ext == ".png" || ext == ".jpg" || ext == ".jpeg" || ext == ".gif" || ext == ".pdf" || ext == ".zip" {
|
|
65
|
+
return nil
|
|
66
|
+
}
|
|
67
|
+
scanner := bufio.NewScanner(file)
|
|
68
|
+
lineNum := 0
|
|
69
|
+
|
|
70
|
+
for scanner.Scan() {
|
|
71
|
+
lineNum++
|
|
72
|
+
line := scanner.Text()
|
|
73
|
+
if strings.Contains(strings.ToLower(line), strings.ToLower(query)) {
|
|
74
|
+
results = append(results, fmt.Sprintf("%s:%d: %s", relPath, lineNum, strings.TrimSpace(line)))
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return nil
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
if err != nil {
|
|
82
|
+
return Failure("search_code", ErrCodeExecution, err.Error(), ""), nil
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return Success("search_code", strings.Join(results, "\n")), nil
|
|
86
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// - ReadFile: file reading
|
|
2
|
+
// - WriteFile: file writing
|
|
3
|
+
// - ListFiles: file listing
|
|
4
|
+
// - SearchCode: code search
|
|
5
|
+
// - ApplyPatch: patch application
|
|
6
|
+
package tools
|
|
7
|
+
|
|
8
|
+
import (
|
|
9
|
+
"fmt"
|
|
10
|
+
|
|
11
|
+
"github.com/furkanbeydemir/orch/internal/models"
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
type Tool interface {
|
|
15
|
+
Name() string
|
|
16
|
+
|
|
17
|
+
Description() string
|
|
18
|
+
|
|
19
|
+
Execute(params map[string]string) (*models.ToolResult, error)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
type Registry struct {
|
|
23
|
+
tools map[string]Tool
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
func NewRegistry() *Registry {
|
|
27
|
+
return &Registry{
|
|
28
|
+
tools: make(map[string]Tool),
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
func (r *Registry) Register(tool Tool) {
|
|
33
|
+
r.tools[tool.Name()] = tool
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
func (r *Registry) Get(name string) (Tool, error) {
|
|
37
|
+
tool, ok := r.tools[name]
|
|
38
|
+
if !ok {
|
|
39
|
+
return nil, fmt.Errorf("tool not found: %s", name)
|
|
40
|
+
}
|
|
41
|
+
return tool, nil
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
func (r *Registry) Execute(name string, params map[string]string) (*models.ToolResult, error) {
|
|
45
|
+
tool, ok := r.tools[name]
|
|
46
|
+
if !ok {
|
|
47
|
+
return Failure(name, ErrCodeToolNotFound, fmt.Sprintf("tool not found: %s", name), ""), nil
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
result, err := tool.Execute(params)
|
|
51
|
+
if err != nil {
|
|
52
|
+
return Failure(name, ErrCodeExecution, err.Error(), ""), nil
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if result == nil {
|
|
56
|
+
return Failure(name, ErrCodeExecution, "tool returned nil result", ""), nil
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return result, nil
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
func (r *Registry) List() []string {
|
|
63
|
+
names := make([]string, 0, len(r.tools))
|
|
64
|
+
for name := range r.tools {
|
|
65
|
+
names = append(names, name)
|
|
66
|
+
}
|
|
67
|
+
return names
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
func DefaultRegistry(repoRoot string) *Registry {
|
|
71
|
+
return DefaultRegistryWithPolicy(repoRoot, Policy{Mode: ModeRun}, nil)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
func DefaultRegistryWithPolicy(repoRoot string, policy Policy, logf func(string)) *Registry {
|
|
75
|
+
reg := NewRegistry()
|
|
76
|
+
|
|
77
|
+
reg.Register(wrapWithPolicy(NewReadFileTool(repoRoot), policy, logf))
|
|
78
|
+
reg.Register(wrapWithPolicy(NewWriteFileTool(repoRoot), policy, logf))
|
|
79
|
+
reg.Register(wrapWithPolicy(NewListFilesTool(repoRoot), policy, logf))
|
|
80
|
+
|
|
81
|
+
reg.Register(wrapWithPolicy(NewSearchCodeTool(repoRoot), policy, logf))
|
|
82
|
+
|
|
83
|
+
reg.Register(wrapWithPolicy(NewRunCommandTool(repoRoot), policy, logf))
|
|
84
|
+
reg.Register(wrapWithPolicy(NewRunTestsTool(repoRoot), policy, logf))
|
|
85
|
+
|
|
86
|
+
reg.Register(wrapWithPolicy(NewGitDiffTool(repoRoot), policy, logf))
|
|
87
|
+
reg.Register(wrapWithPolicy(NewApplyPatchTool(repoRoot), policy, logf))
|
|
88
|
+
|
|
89
|
+
return reg
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
func wrapWithPolicy(tool Tool, policy Policy, logf func(string)) Tool {
|
|
93
|
+
return &guardedTool{inner: tool, policy: policy, logf: logf}
|
|
94
|
+
}
|