Files
agent-radar 2ed3415fb7
Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
2026-09-01 20:17:27 +08:00

302 lines
9.5 KiB
Go

package agentruntime
// OpenCode exposes build/plan as ACP session modes. Those modes select a
// workflow agent and are intentionally independent from Tutti's permission
// tiers. Permissions are enforced through OpenCode's permission config plus
// client-side resolution of ACP permission requests.
import (
"encoding/json"
"fmt"
"os"
"strings"
"github.com/tutti-os/tutti/packages/agent/daemon/providerregistry"
)
const (
openCodePermissionReadOnly = "read-only"
openCodePermissionAsk = "ask"
openCodePermissionFullAccess = "full-access"
openCodePermissionEnv = "OPENCODE_PERMISSION"
openCodeSparkModelID = providerregistry.OpenCodeSparkModelID
)
func newOpenCodeAdapterFromProviderDescriptor(
descriptor providerregistry.ProviderDescriptor,
transport ProcessTransport,
host HostMetadata,
commandResolver ProviderCommandResolver,
) *standardACPAdapter {
adapter := newStandardACPAdapterFromProviderDescriptor(descriptor, transport, host, commandResolver)
settingsEnvironment := descriptor.Runtime.StandardACP.SettingsEnvironment
adapter.config.env = func(session Session) []string {
return standardACPEnv(session, host)
}
adapter.config.finalizeEnv = func(env []string, session Session) ([]string, error) {
return openCodeFinalEnv(settingsEnvironment, session, os.Getenv(settingsEnvironment.Variable), env)
}
adapter.config.automaticPermissionDecision = openCodeAutomaticPermissionDecision
adapter.config.filterPermissionOptions = openCodePermissionOptions
adapter.config.validateSettings = validateOpenCodeSettings
adapter.config.filterRuntimeConfigOptionDescriptors = filterOpenCodeRuntimeConfigOptionDescriptors
adapter.config.filterRuntimeConfigOptionValues = filterOpenCodeRuntimeConfigOptionValues
return adapter
}
func validateOpenCodeSettings(session Session, patch SessionSettingsPatch) error {
settings := session.SettingsValue()
if patch.Model != nil {
settings.Model = strings.TrimSpace(*patch.Model)
}
if patch.ReasoningEffort != nil {
settings.ReasoningEffort = strings.TrimSpace(*patch.ReasoningEffort)
}
model := strings.TrimSpace(settings.Model)
effort := strings.TrimSpace(settings.ReasoningEffort)
if providerregistry.OpenCodeModelSupportsReasoningEffort(model, effort) {
return nil
}
// OpenCode's generic ACP descriptor advertises "none", but the selected
// Spark model rejects it at session/prompt. Do not silently choose a
// different reasoning level; the service-side model catalog filters this
// value for normal launches, while this adapter guard protects stale or
// out-of-band settings before any prompt is sent.
return fmt.Errorf(
"OpenCode model %q does not support reasoning effort %q; supported values: low, medium, high, xhigh",
model,
effort,
)
}
func filterOpenCodeRuntimeConfigOptionDescriptors(
session Session,
descriptors []map[string]any,
) []map[string]any {
filtered := cloneConfigOptionDescriptors(descriptors)
if len(filtered) == 0 {
return filtered
}
currentModel := strings.TrimSpace(session.SettingsValue().Model)
for _, descriptor := range filtered {
if strings.TrimSpace(asString(descriptor["id"])) == "model" {
if currentModel == "" {
currentModel = strings.TrimSpace(asString(descriptor["currentValue"]))
}
for _, option := range configOptionEntries(descriptor["options"]) {
modelID := strings.TrimSpace(asString(option["value"]))
if strings.EqualFold(modelID, openCodeSparkModelID) {
filterOpenCodeModelReasoningEfforts(option)
}
}
continue
}
if strings.TrimSpace(asString(descriptor["id"])) == "reasoning_effort" &&
strings.EqualFold(currentModel, openCodeSparkModelID) {
filterOpenCodeReasoningDescriptor(descriptor)
}
}
return filtered
}
func filterOpenCodeRuntimeConfigOptionValues(
session Session,
values map[string]any,
) map[string]any {
filtered := clonePayload(values)
if len(filtered) == 0 {
return filtered
}
currentModel := strings.TrimSpace(asString(filtered["model"]))
if currentModel == "" {
currentModel = strings.TrimSpace(session.SettingsValue().Model)
}
if strings.EqualFold(currentModel, openCodeSparkModelID) &&
!providerregistry.OpenCodeModelSupportsReasoningEffort(
openCodeSparkModelID,
asString(filtered["reasoning_effort"]),
) {
delete(filtered, "reasoning_effort")
}
return filtered
}
func filterOpenCodeModelReasoningEfforts(option map[string]any) {
efforts := configOptionEntries(option["reasoningEfforts"])
if len(efforts) == 0 {
return
}
filtered := make([]map[string]any, 0, len(efforts))
for _, effort := range efforts {
if providerregistry.OpenCodeModelSupportsReasoningEffort(
openCodeSparkModelID,
asString(effort["value"]),
) {
filtered = append(filtered, effort)
}
}
option["reasoningEfforts"] = filtered
if !providerregistry.OpenCodeModelSupportsReasoningEffort(
openCodeSparkModelID,
asString(option["reasoningEffort"]),
) {
if len(filtered) > 0 {
option["reasoningEffort"] = asString(filtered[0]["value"])
} else {
delete(option, "reasoningEffort")
}
}
}
func filterOpenCodeReasoningDescriptor(descriptor map[string]any) {
efforts := configOptionEntries(descriptor["options"])
filtered := make([]map[string]any, 0, len(efforts))
for _, effort := range efforts {
if providerregistry.OpenCodeModelSupportsReasoningEffort(
openCodeSparkModelID,
asString(effort["value"]),
) {
filtered = append(filtered, effort)
}
}
descriptor["options"] = filtered
if !providerregistry.OpenCodeModelSupportsReasoningEffort(
openCodeSparkModelID,
asString(descriptor["currentValue"]),
) && len(filtered) > 0 {
descriptor["currentValue"] = asString(filtered[0]["value"])
}
}
func openCodeConfigContent(
descriptor providerregistry.RuntimeSettingsEnvironmentDescriptor,
session Session,
baseContent string,
) (string, error) {
config := map[string]any{}
if strings.TrimSpace(baseContent) != "" {
if err := json.Unmarshal([]byte(baseContent), &config); err != nil {
return "", fmt.Errorf("decode %s: %w", descriptor.Variable, err)
}
if config == nil {
config = map[string]any{}
}
}
if settings := runtimeSettingsEnvironmentValue(descriptor, session); settings != "" {
var generated map[string]any
if err := json.Unmarshal([]byte(settings), &generated); err != nil {
return "", fmt.Errorf("decode generated %s: %w", descriptor.Variable, err)
}
for key, value := range generated {
config[key] = value
}
}
config["permission"] = openCodeInteractivePermissionRules()
agents, _ := config["agent"].(map[string]any)
if agents == nil {
agents = map[string]any{}
}
plan, _ := agents["plan"].(map[string]any)
if plan == nil {
plan = map[string]any{}
}
planPermission, _ := plan["permission"].(map[string]any)
if planPermission == nil {
planPermission = map[string]any{}
}
planPermission["edit"] = "deny"
plan["permission"] = planPermission
agents["plan"] = plan
config["agent"] = agents
data, err := json.Marshal(config)
if err != nil {
return "", fmt.Errorf("encode %s: %w", descriptor.Variable, err)
}
return string(data), nil
}
func openCodeFinalEnv(
descriptor providerregistry.RuntimeSettingsEnvironmentDescriptor,
session Session,
inheritedContent string,
env []string,
) ([]string, error) {
variable := strings.TrimSpace(descriptor.Variable)
baseContent := inheritedContent
for index := len(env) - 1; index >= 0; index-- {
key, value, ok := strings.Cut(env[index], "=")
if ok && strings.EqualFold(strings.TrimSpace(key), variable) {
baseContent = value
break
}
}
content, err := openCodeConfigContent(descriptor, session, baseContent)
if err != nil {
return nil, err
}
result := make([]string, 0, len(env)+1)
for _, item := range env {
key, _, ok := strings.Cut(item, "=")
if ok && (strings.EqualFold(strings.TrimSpace(key), variable) ||
strings.EqualFold(strings.TrimSpace(key), openCodePermissionEnv)) {
continue
}
result = append(result, item)
}
return append(result, openCodePermissionEnv+"={}", variable+"="+content), nil
}
// OpenCode's default policy allows most tools without consulting the ACP
// client. This baseline keeps local read/search operations immediate while
// routing every other tool through session/request_permission, which lets the
// selected Tutti tier ask, approve, or deny it live. The protected .env rules
// mirror OpenCode's built-in defaults.
func openCodeInteractivePermissionRules() map[string]any {
return map[string]any{
"*": "ask",
"glob": "allow",
"grep": "allow",
"list": "allow",
"lsp": "allow",
"plan_enter": "allow",
"plan_exit": "allow",
"question": "allow",
"read": map[string]any{
"*": "allow",
"*.env": "deny",
"*.env.*": "deny",
"*.env.example": "allow",
},
"skill": "allow",
"todowrite": "allow",
}
}
func openCodeAutomaticPermissionDecision(permissionModeID string) string {
switch strings.TrimSpace(permissionModeID) {
case openCodePermissionReadOnly:
return "denied"
case openCodePermissionFullAccess:
return "approved"
case openCodePermissionAsk:
return ""
default:
return ""
}
}
// "Always allow" mutates OpenCode's in-memory permission rules and cannot be
// revoked by an ACP permission-tier change. Keep approvals one-shot so moving
// from Ask or Full access to Read-only takes effect on the next request.
func openCodePermissionOptions(options []map[string]any) []map[string]any {
result := make([]map[string]any, 0, len(options))
for _, option := range options {
kind := normalizePermissionOptionToken(asString(option["kind"]))
if kind == "allowalways" {
continue
}
result = append(result, option)
}
return result
}