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
302 lines
9.5 KiB
Go
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
|
|
}
|