d83c1dd982
* feat(diff): use stored base_branch for diff stats + per-task compare picker
Task-card additions/deletions and the Changes panel were computed against a
hardcoded origin/main → master priority list inside agentctl. For tasks
branched off a release/staging branch this inflated the counts by every
commit since the actual fork point, and the Changes panel grouped unrelated
files alongside the task's real work.
This wires the per-repo task_repositories.base_branch end-to-end into the
agentctl WorkspaceTracker so each repo's diff stats resolve against the
recorded base instead of the integration-branch fallback. A picker on the
Changes panel branch hover card lets the user change the base mid-task; the
new value is persisted via PATCH /tasks/:id/repositories/:repo_id, pushed
live to the running agentctl instance, and the session's cached
base_commit_sha is cleared so the commits panel + cumulative diff refetch
against the new base without a session restart.
Falls back to the existing origin/main → master list for legacy tasks
(empty base_branch) and to the branch tip when no merge-base exists with
HEAD (unrelated histories — local rebase backups, etc.) so the stats and
commits panels stay anchored on the same SHA in every failure mode.
* fix(agentctl): sanitize user-controlled base_branch refs before git exec
CodeQL flagged the picker's user-controlled base_branch payload reaching
exec.Command("git", ..., baseBranch) inside workspace_git_cmd.go as a
command-built-from-user-controlled-source vulnerability. A malicious caller
could smuggle a leading-"-" argument like "-upload-pack=..." that git
itself reinterprets as a flag, even though exec.Command keeps args
separate from the shell.
Adds an IsSafeGitRef sanitizer at every boundary the value crosses on its
way to git: the service write path, the agentctl HTTP handlers
(/workspace/base-branches, /git/log, /git/cumulative-diff), and
WorkspaceTracker.SetBaseBranch itself. Unsafe values are rejected at the
service tier and silently downgraded to the no-override fallback inside
agentctl so a misbehaving caller can't half-break the tracker.
* fix(diff): address PR review blockers and CI lint
- Split UpdateRepositoryBaseBranch into validate/load/apply helpers to drop
cyclomatic complexity below the cyclop limit that gated Run Backend Tests.
- Replace direct m.cfg.BaseBranches reads/writes with getBaseBranches /
setBaseBranches under a dedicated RWMutex. UpdateBaseBranches used to
write under repoTrackersMu while rescan + lazy-subpath callers read under
workspaceTrackersMu, leaving an unsynchronized data race on the map.
- Drop the false 'PR target is auto-wired' claim from the MCP tool
description — GitOperator.CreatePR takes baseBranch as an explicit arg
and never reads the tracker's value.
- Picker no-op guard now compares against currentBase instead of storedBase
so selecting the displayed fallback (legacy task before any picker
change) is treated as no-op instead of triggering a spurious save.
- HTTP handler now maps non-validation service errors to 500 instead of
400, classifying via the existing isValidationError helper plus the new
unsafe-git-ref message.
* fix(diff): regex-based ref sanitiser at every git subprocess sink
CodeQL kept flagging workspace_git_cmd.go:43 even after SetBaseBranch
rejected unsafe values — the taint tracker doesn't follow sanitised stores
through struct fields, so it still saw user input flowing into
exec.Command("git", ..., baseBranch).
Switches IsSafeGitRef to a single regex check (a pattern CodeQL recognises
as a sanitiser barrier) and adds SanitizeGitRef calls inline at every
agentctl call site that splices a user-controlled ref into a git arg:
- workspace_git_status.resolveStoredRef (before the rev-parse probes)
- workspace_git_status.computeBaseCommit (before merge-base/rev-parse)
- workspace_git_status.getAheadBehindCounts (before rev-list)
- agentctl/server/api/git.go runGitLogForRepo + handleGitCumulativeDiff
(before computeMergeBase/GetRevParse on the HTTP TargetBranch)
Behaviour is unchanged for valid refs — only narrows the allowlist (now
[A-Za-z0-9_./-]) so the sanitiser barrier is recognisable to static
analysis. SetBaseBranch's field-store sanitiser stays as defence in depth.
* fix(diff): transform sanitiser at every map read for CodeQL taint flow
CodeQL still flagged workspace_git_cmd.go:43 because:
- handleSetBaseBranches used an IsSafeGitRef bool guard before a verbatim
map[k] = v copy. CodeQL recognises regex-transforming sanitisers but not
bool-guarded passthroughs.
- lookupBaseBranch returned m.cfg.BaseBranches[k] verbatim. Even though
the map was sanitised on write, taint flow across map stores + field
stores loses the sanitised state in the analyser.
Switches both sites to the transforming SanitizeGitRef helper so every
read produces a freshly-sanitised value before it flows to a downstream
git argument. Behaviour is unchanged for valid refs.
* fix(diff): regex transformer sanitiser + guard live push from empty map
CodeQL still flagged the previous SanitizeGitRef because it returned the
input string unchanged on success — an identity passthrough that the taint
tracker doesn't recognise as a sanitiser barrier.
Switches to safeGitRefPattern.FindString so the output comes from the
regex match (not the parameter). IsSafeGitRef now wraps SanitizeGitRef so
the predicate and the transformer share one allowlist.
Also addresses two Claude review blockers:
- collectTaskBaseBranches returns a nil/empty map when the task has no
recorded base_branches; pushing that to agentctl wipes every tracker's
override. Now skipped — the DB row is the source of truth for the next
session launch.
- applyBaseBranchSideEffects now runs on context.WithoutCancel so an HTTP
/ WS request cancel after the task_repositories UPDATE commits can't
leave session.base_commit_sha stale + the live agentctl tracker map
un-pushed, drifting from the persisted row.
* fix(diff): share securityutil.IsValidBranchName + harden error classification
CodeQL still flagged the picker's regex sanitiser as taint passthrough.
Refactors SanitizeGitRef / isSafeBaseBranchRef to delegate to the existing
securityutil.IsValidBranchName helper that agentctl's Rebase / Merge /
RenameBranch already use without being flagged. The shared allowlist also
fixes a Claude review blocker: 'feature/@2024' used to pass the service
validator but get dropped by the agentctl-side check at push time,
silently wiping every tracker's override.
Plus the two error-classification blockers Claude raised on the previous
HEAD:
- WS handler (task_ws_handlers.go wsUpdateTaskRepository) now distinguishes
validation errors (surfaced verbatim) from internal faults (opaque
500-style message). HTTP path already did this via the existing
isValidationError helper.
- MCP handler (handlers.handleUpdateRepositoryBaseBranch) gets the same
treatment via a new isValidationError local to the mcp/handlers
package so raw service error strings stop leaking across the MCP
boundary.
* fix(diff): inline securityutil.IsValidBranchName at every git subprocess sink
CodeQL's interprocedural taint tracking wasn't recognising SanitizeGitRef
as a sanitiser even after it delegated to securityutil.IsValidBranchName
— the wrapper helper + map/field hops between source and sink kept the
flow tainted in the analyser.
Inlines the IsValidBranchName allowlist check (already used inline by
Rebase/Merge in git.go without being flagged) at every call site that
splices a user-controlled ref into a git subprocess argument:
- workspace_git_status.resolveStoredRef (before rev-parse --verify)
- workspace_git_status.computeBaseCommit (before merge-base / rev-parse)
- workspace_git_status.getAheadBehindCounts (before rev-list)
- agentctl/server/api/git.go runGitLogForRepo + handleGitCumulativeDiff
- agentctl/server/api/workspace_base_branches.go handleSetBaseBranches
Behaviour unchanged: same allowlist as Rebase / Merge / RenameBranch
already use. SanitizeGitRef / IsSafeGitRef survive as defence-in-depth
helpers for callers that want a one-line check; they delegate to the same
securityutil helper so the rules stay aligned.
* fix(diff): inline regexp.MatchString at every git subprocess sink
CodeQL still flagged workspace_git_cmd.go:43 even with the inline
securityutil.IsValidBranchName helper — the taint tracker apparently does
not follow into helper functions for the go/command-injection rule on
this path.
Hoists the regex to package-scope vars (safeBranchRefPattern in both
process and api packages) and calls regexp.MatchString directly at each
sink site. Behaviour is identical to securityutil.IsValidBranchName; only
the call form changes.
* fix(diff): SHA-shape allowlist on BaseCommit before downstream git diff
CodeQL still flagged workspace_git_cmd.go:43 because update.BaseCommit
is the OUTPUT of an earlier merge-base / rev-parse call whose argument
list contained a user-controlled branch ref — the analyser treats that
command's output as still tainted even when the input ref was sanitised.
Adds an inline 40-hex SHA-shape allowlist check in enrichWithBranchDiff
right before `git diff --numstat <update.BaseCommit>` so the sanitiser
barrier sits in the same function as the next subprocess call. Behaviour
is unchanged: merge-base / rev-parse already produce SHA-shaped output.
* fix(diff): async tracker refresh + tighten init spread order
Two CodeRabbit/Claude review fixes:
- UpdateBaseBranches stamps each tracker's new baseBranch synchronously
then runs RefreshGitStatus on a background goroutine instead of inside
the picker-save HTTP handler. Per-tracker git subprocesses can each
take ~3-5 calls; serialising them on the request goroutine made the
PATCH response wait for every refresh to finish. The picker UI re-fetches
via the existing WS stream once Refresh emits, so the handler doesn't
need to block.
- updateTaskRepositoryBaseBranch now spreads options.init BEFORE the
required method/body fields so an upstream caller's init overrides
can't replace the PATCH method or the base_branch payload. Matches
the convention deleteTask / moveTask already follow.
* fix(diff): narrow mcp isValidationError + opaque 500 + post-sanitize log count
Three final Claude review threads on the latest commit:
- mcp/handlers isValidationError drops the broad 'invalid' substring so
DB / IO errors whose messages happen to contain 'invalid' don't
misclassify as ErrorCodeValidation.
- task_http_handlers httpUpdateTaskRepository now logs the raw service
error server-side and returns an opaque 'failed to update task
repository' on the 500 path. Stops potential DB / IO detail leakage
through the HTTP boundary.
- workspace_base_branches debug log reports accepted + rejected counts
(post-sanitisation) instead of the pre-filter total so the metric
actually reflects what hit UpdateBaseBranches.
---------
Co-authored-by: Kandev Agent <agent@kandev.dev>
552 lines
18 KiB
Go
552 lines
18 KiB
Go
// Package config provides unified configuration for agentctl.
|
|
//
|
|
// agentctl is runtime-agnostic - it behaves identically whether running
|
|
// inside a Docker container or directly on the host. The caller (kandev backend)
|
|
// handles any Docker vs standalone differences.
|
|
//
|
|
// Configuration hierarchy:
|
|
// - Config: Global server settings (ports, logging, instance limits)
|
|
// - Config.Defaults: Default values for new instances
|
|
// - InstanceConfig: Per-instance settings (derived from Defaults + overrides)
|
|
package config
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"crypto/subtle"
|
|
"encoding/hex"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/kandev/kandev/pkg/agent"
|
|
)
|
|
|
|
// Config is the agentctl configuration.
|
|
// agentctl always exposes the same instance management API regardless of
|
|
// deployment context (Docker container or host machine).
|
|
type Config struct {
|
|
// Port is the control/API server port
|
|
Port int
|
|
|
|
// Ports configures the port range for instance allocation
|
|
Ports PortConfig
|
|
|
|
// Defaults provides default values for new instances
|
|
Defaults InstanceDefaults
|
|
|
|
// Shell configuration
|
|
ShellEnabled bool // Enable auto-shell feature (default: true)
|
|
|
|
// Logging configuration
|
|
LogLevel string
|
|
LogFormat string
|
|
McpLogFile string // Optional file path for MCP debug logs
|
|
|
|
// VS Code server configuration
|
|
VscodeCommand string // Command to run code-server (default: "code-server")
|
|
|
|
// AuthToken is a shared secret for authenticating requests from the kandev backend.
|
|
// Generated internally when AGENTCTL_BOOTSTRAP_NONCE is present.
|
|
// Empty means authentication is disabled (e.g. dev/test without nonce).
|
|
AuthToken string
|
|
|
|
// BootstrapNonce is a one-time-use nonce for the handshake protocol.
|
|
// When set (via AGENTCTL_BOOTSTRAP_NONCE), agentctl generates its own AuthToken.
|
|
// at startup and exposes POST /auth/handshake for the backend to retrieve it.
|
|
// The nonce is burned after a single successful handshake.
|
|
BootstrapNonce string
|
|
|
|
// IdleTimeout is the duration after which an instance with no in-flight
|
|
// requests and no recent HTTP activity is reaped by the instance
|
|
// manager. Sourced from KANDEV_ACP_IDLE_TIMEOUT (default 1h).
|
|
// Setting it to 0 disables the reaper.
|
|
IdleTimeout time.Duration
|
|
|
|
// IdleReaperInterval is how often the idle reaper scans for stale
|
|
// instances. Sourced from KANDEV_ACP_IDLE_REAPER_INTERVAL (default 1m).
|
|
// Only the test suite needs to override this; production code should
|
|
// rely on the default.
|
|
IdleReaperInterval time.Duration
|
|
|
|
// mu protects BootstrapNonce from concurrent access during handshake.
|
|
mu sync.Mutex
|
|
}
|
|
|
|
// PortConfig defines port allocation for instances
|
|
type PortConfig struct {
|
|
// Base is the starting port for instance allocation (multi-instance mode)
|
|
Base int
|
|
// Max is the maximum port for instance allocation
|
|
Max int
|
|
}
|
|
|
|
// InstanceDefaults provides default values for new instances.
|
|
// These can be overridden when creating an instance.
|
|
type InstanceDefaults struct {
|
|
// Protocol for agent communication (acp, codex, mcp)
|
|
Protocol agent.Protocol
|
|
|
|
// AgentCommand is the command to run the agent (e.g., "auggie --acp")
|
|
AgentCommand string
|
|
|
|
// WorkDir is the default working directory
|
|
WorkDir string
|
|
|
|
// AutoStart starts the agent automatically when the instance is created
|
|
AutoStart bool
|
|
|
|
// AutoApprovePermissions auto-approves permission requests (for testing/CI)
|
|
AutoApprovePermissions bool
|
|
|
|
// HealthCheckInterval is the interval in seconds for health checks
|
|
HealthCheckInterval int
|
|
|
|
// ProcessBufferMaxBytes is the max bytes per process output buffer (default 2MB)
|
|
ProcessBufferMaxBytes int64
|
|
}
|
|
|
|
// McpServerConfig holds configuration for an MCP server.
|
|
type McpServerConfig struct {
|
|
// Name is the human-readable name of the MCP server
|
|
Name string `json:"name"`
|
|
// URL is the URL for HTTP/SSE transport
|
|
URL string `json:"url,omitempty"`
|
|
// Type is the transport type: "stdio", "sse", "http", or "streamable_http"
|
|
Type string `json:"type,omitempty"`
|
|
// Command is the command for stdio transport
|
|
Command string `json:"command,omitempty"`
|
|
// Args are the arguments for stdio transport
|
|
Args []string `json:"args,omitempty"`
|
|
// Env holds environment variables for stdio transport
|
|
Env map[string]string `json:"env,omitempty"`
|
|
// Headers holds HTTP headers for SSE/HTTP transport
|
|
Headers map[string]string `json:"headers,omitempty"`
|
|
}
|
|
|
|
// InstanceConfig holds configuration for a single agent instance.
|
|
// This is passed to the process manager and API server.
|
|
type InstanceConfig struct {
|
|
// InstanceID is the stable identifier the InstanceManager assigned to
|
|
// this instance. Used by the API middleware to reject requests whose
|
|
// X-Instance-ID header doesn't match — a stale client that still
|
|
// targets a port that has since been recycled to a new instance
|
|
// (e.g. after the previous instance was deleted) gets 404 instead
|
|
// of accidentally configuring or starting the new instance's agent.
|
|
InstanceID string
|
|
|
|
// Port is the HTTP server port for this instance
|
|
Port int
|
|
|
|
// Protocol for agent communication
|
|
Protocol agent.Protocol
|
|
|
|
// AgentCommand is the command to run the agent
|
|
AgentCommand string
|
|
|
|
// AgentArgs is the parsed command (derived from AgentCommand)
|
|
AgentArgs []string
|
|
|
|
// WorkDir is the working directory for the agent process
|
|
WorkDir string
|
|
|
|
// AgentEnv is the environment variables to pass to the agent
|
|
AgentEnv []string
|
|
|
|
// AutoStart starts the agent automatically
|
|
AutoStart bool
|
|
|
|
// AutoApprovePermissions auto-approves permission requests
|
|
AutoApprovePermissions bool
|
|
|
|
// ApprovalPolicy controls when the agent requests approval.
|
|
// Valid values: "untrusted" (always), "on-failure", "on-request", "never".
|
|
// Defaults to "on-request" if empty.
|
|
ApprovalPolicy string
|
|
|
|
// ShellEnabled enables auto-shell feature
|
|
ShellEnabled bool
|
|
|
|
// LogLevel for this instance
|
|
LogLevel string
|
|
|
|
// LogFormat for this instance
|
|
LogFormat string
|
|
|
|
// AgentType identifies the agent (e.g., "auggie", "codex", "claude")
|
|
// Used for agent-specific adapter selection
|
|
AgentType string
|
|
|
|
// McpServers is a list of MCP servers to configure for the agent
|
|
McpServers []McpServerConfig
|
|
|
|
// ProcessBufferMaxBytes caps per-process output buffer size
|
|
ProcessBufferMaxBytes int64
|
|
|
|
// SessionID is the session ID for this agent instance (used in MCP tool calls)
|
|
SessionID string
|
|
|
|
// TaskID is the task ID for this agent instance (used in MCP plan tool calls)
|
|
TaskID string
|
|
|
|
// ContinueCommand is the command template for follow-up prompts in one-shot agents.
|
|
// When set, the adapter spawns a new process per prompt using this command for
|
|
// continuation (thread ID appended at runtime). Only used by Amp.
|
|
ContinueCommand string
|
|
|
|
// VscodeCommand is the command to run the VS Code server (e.g., "code-server")
|
|
VscodeCommand string
|
|
|
|
// DisableAskQuestion disables the ask_user_question MCP tool.
|
|
// Used for TUI/passthrough agents that don't need clarification tools.
|
|
DisableAskQuestion bool
|
|
|
|
// AssumeMcpSse overrides MCP capability filtering to assume the agent supports SSE.
|
|
AssumeMcpSse bool
|
|
|
|
// AssumeMcpHttp overrides MCP capability filtering to assume the agent supports HTTP.
|
|
AssumeMcpHttp bool
|
|
|
|
// McpMode controls which MCP tools are registered for this instance.
|
|
// "task" (default) registers kanban/plan tools; "config" registers config tools.
|
|
McpMode string
|
|
|
|
// AuthToken is a shared secret for authenticating requests.
|
|
// Inherited from the parent Config at instance creation time.
|
|
AuthToken string
|
|
|
|
// RequiresProcessKill forces the agent's process group to be killed on
|
|
// shutdown instead of relying on stdin close. Required for agents whose
|
|
// runtime keeps child processes alive when stdin closes (opencode acp).
|
|
RequiresProcessKill bool
|
|
|
|
// BaseBranches maps RepositoryName → base branch ref for per-repo diff
|
|
// stats. The empty key "" applies to the root / single-repo tracker.
|
|
// process.Manager reads this at construction and stamps each
|
|
// WorkspaceTracker's baseBranch. Empty falls back to the hardcoded
|
|
// origin/main → master priority list inside workspace_git_status.go.
|
|
BaseBranches map[string]string
|
|
}
|
|
|
|
// Load loads the configuration from environment variables.
|
|
func Load() *Config {
|
|
cfg := &Config{
|
|
Port: getEnvInt("AGENTCTL_PORT", 39429),
|
|
Ports: PortConfig{
|
|
Base: getEnvInt("AGENTCTL_INSTANCE_PORT_BASE", 41001),
|
|
Max: getEnvInt("AGENTCTL_INSTANCE_PORT_MAX", 41100),
|
|
},
|
|
Defaults: InstanceDefaults{
|
|
Protocol: agent.Protocol(getEnv("AGENTCTL_PROTOCOL", string(agent.ProtocolACP))),
|
|
AgentCommand: getEnv("AGENTCTL_AGENT_COMMAND", "auggie --acp"),
|
|
WorkDir: getEnv("AGENTCTL_WORKDIR", "/workspace"),
|
|
AutoStart: getEnvBool("AGENTCTL_AUTO_START", false),
|
|
AutoApprovePermissions: getEnvBool("AGENTCTL_AUTO_APPROVE_PERMISSIONS", false),
|
|
HealthCheckInterval: getEnvInt("AGENTCTL_HEALTH_CHECK_INTERVAL", 5),
|
|
ProcessBufferMaxBytes: getEnvInt64("AGENTCTL_PROCESS_BUFFER_MAX_BYTES", 2*1024*1024),
|
|
},
|
|
ShellEnabled: getEnvBool("AGENTCTL_SHELL_ENABLED", true),
|
|
LogLevel: getEnvWithFallback("AGENTCTL_LOG_LEVEL", "KANDEV_LOG_LEVEL", "info"),
|
|
LogFormat: getEnv("AGENTCTL_LOG_FORMAT", "json"),
|
|
McpLogFile: getEnv("KANDEV_MCP_LOG_FILE", ""),
|
|
VscodeCommand: getEnv("AGENTCTL_VSCODE_COMMAND", "code-server"),
|
|
IdleTimeout: getEnvDuration("KANDEV_ACP_IDLE_TIMEOUT", time.Hour),
|
|
IdleReaperInterval: getEnvDuration("KANDEV_ACP_IDLE_REAPER_INTERVAL", time.Minute),
|
|
}
|
|
|
|
// Bootstrap nonce mode: agentctl generates its own token and the backend
|
|
// retrieves it via handshake using the nonce as proof-of-parentage.
|
|
if nonce := os.Getenv("AGENTCTL_BOOTSTRAP_NONCE"); nonce != "" {
|
|
cfg.BootstrapNonce = nonce
|
|
cfg.AuthToken = generateSelfToken()
|
|
}
|
|
|
|
return cfg
|
|
}
|
|
|
|
// ConsumeNonce atomically validates and burns the bootstrap nonce.
|
|
// Returns the auth token if the nonce matches, empty string otherwise.
|
|
// The nonce is invalidated after a single successful call (one-shot).
|
|
func (c *Config) ConsumeNonce(nonce string) string {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
|
|
if c.BootstrapNonce == "" || nonce == "" {
|
|
return ""
|
|
}
|
|
if subtle.ConstantTimeCompare([]byte(c.BootstrapNonce), []byte(nonce)) != 1 {
|
|
return ""
|
|
}
|
|
|
|
// Burn the nonce — only one handshake allowed
|
|
c.BootstrapNonce = ""
|
|
return c.AuthToken
|
|
}
|
|
|
|
// generateSelfToken creates a cryptographically random 32-byte hex-encoded token.
|
|
func generateSelfToken() string {
|
|
b := make([]byte, 32)
|
|
if _, err := rand.Read(b); err != nil {
|
|
// Fallback should never happen; crypto/rand rarely fails.
|
|
panic("crypto/rand failed: " + err.Error())
|
|
}
|
|
return hex.EncodeToString(b)
|
|
}
|
|
|
|
// NewInstanceConfig creates an InstanceConfig from defaults with optional overrides.
|
|
// If port is 0, it should be allocated by the caller.
|
|
func (c *Config) NewInstanceConfig(port int, overrides *InstanceOverrides) *InstanceConfig {
|
|
cfg := &InstanceConfig{
|
|
Port: port,
|
|
Protocol: c.Defaults.Protocol,
|
|
AgentCommand: c.Defaults.AgentCommand,
|
|
WorkDir: c.Defaults.WorkDir,
|
|
AutoStart: c.Defaults.AutoStart,
|
|
AutoApprovePermissions: c.Defaults.AutoApprovePermissions,
|
|
ShellEnabled: c.ShellEnabled,
|
|
LogLevel: c.LogLevel,
|
|
LogFormat: c.LogFormat,
|
|
ProcessBufferMaxBytes: c.Defaults.ProcessBufferMaxBytes,
|
|
VscodeCommand: c.VscodeCommand,
|
|
McpMode: "task",
|
|
AuthToken: c.AuthToken,
|
|
}
|
|
|
|
applyOverrides(cfg, overrides)
|
|
|
|
// Inject local kandev MCP server for MCP tunneling through the agent stream
|
|
// This ensures the kandev MCP server is available for protocols that read MCP config
|
|
// at startup time (e.g., Codex via -c flags). The MCP server uses the agent stream
|
|
// WebSocket connection (bidirectional) to forward tool calls to the backend.
|
|
if port > 0 {
|
|
cfg.McpServers = injectKandevMcpServer(cfg.McpServers, port)
|
|
}
|
|
|
|
// Parse agent command into args
|
|
cfg.AgentArgs = ParseCommand(cfg.AgentCommand)
|
|
|
|
// Collect environment if not explicitly set
|
|
if cfg.AgentEnv == nil {
|
|
cfg.AgentEnv = CollectAgentEnv(nil)
|
|
}
|
|
|
|
return cfg
|
|
}
|
|
|
|
// applyOverrides applies non-zero fields from overrides to cfg.
|
|
func applyOverrides(cfg *InstanceConfig, overrides *InstanceOverrides) {
|
|
if overrides == nil {
|
|
return
|
|
}
|
|
if overrides.InstanceID != "" {
|
|
cfg.InstanceID = overrides.InstanceID
|
|
}
|
|
if overrides.Protocol != "" {
|
|
cfg.Protocol = overrides.Protocol
|
|
}
|
|
if overrides.AgentCommand != "" {
|
|
cfg.AgentCommand = overrides.AgentCommand
|
|
}
|
|
if overrides.WorkDir != "" {
|
|
cfg.WorkDir = overrides.WorkDir
|
|
}
|
|
if overrides.AutoStart != nil {
|
|
cfg.AutoStart = *overrides.AutoStart
|
|
}
|
|
if overrides.Env != nil {
|
|
cfg.AgentEnv = overrides.Env
|
|
}
|
|
if overrides.ApprovalPolicy != "" {
|
|
cfg.ApprovalPolicy = overrides.ApprovalPolicy
|
|
}
|
|
if overrides.AgentType != "" {
|
|
cfg.AgentType = overrides.AgentType
|
|
}
|
|
if len(overrides.McpServers) > 0 {
|
|
cfg.McpServers = overrides.McpServers
|
|
}
|
|
if overrides.SessionID != "" {
|
|
cfg.SessionID = overrides.SessionID
|
|
}
|
|
if overrides.TaskID != "" {
|
|
cfg.TaskID = overrides.TaskID
|
|
}
|
|
if overrides.DisableAskQuestion {
|
|
cfg.DisableAskQuestion = true
|
|
}
|
|
if overrides.AssumeMcpSse {
|
|
cfg.AssumeMcpSse = true
|
|
}
|
|
if overrides.AssumeMcpHttp {
|
|
cfg.AssumeMcpHttp = true
|
|
}
|
|
if overrides.McpMode != "" {
|
|
cfg.McpMode = overrides.McpMode
|
|
}
|
|
if overrides.RequiresProcessKill {
|
|
cfg.RequiresProcessKill = true
|
|
}
|
|
if len(overrides.BaseBranches) > 0 {
|
|
cfg.BaseBranches = overrides.BaseBranches
|
|
}
|
|
}
|
|
|
|
// InstanceOverrides allows overriding default values when creating an instance
|
|
type InstanceOverrides struct {
|
|
InstanceID string
|
|
Protocol agent.Protocol
|
|
AgentCommand string
|
|
WorkDir string
|
|
AutoStart *bool
|
|
Env []string
|
|
ApprovalPolicy string
|
|
AgentType string
|
|
McpServers []McpServerConfig
|
|
SessionID string
|
|
TaskID string
|
|
DisableAskQuestion bool
|
|
AssumeMcpSse bool
|
|
AssumeMcpHttp bool
|
|
McpMode string
|
|
RequiresProcessKill bool
|
|
BaseBranches map[string]string
|
|
}
|
|
|
|
// ParseCommand splits a command string into arguments
|
|
func ParseCommand(cmd string) []string {
|
|
return strings.Fields(cmd)
|
|
}
|
|
|
|
// CollectAgentEnv collects environment variables to pass to the agent.
|
|
// It filters out AGENTCTL_* variables and optionally merges additional env vars.
|
|
func CollectAgentEnv(additional map[string]string) []string {
|
|
envMap := make(map[string]string)
|
|
|
|
// Start with current environment, excluding AGENTCTL_* and npm_config_* vars.
|
|
// npm_config_* vars from the parent pnpm process cause "Unknown env config"
|
|
// warnings when the agent is launched via npx.
|
|
for _, e := range os.Environ() {
|
|
if idx := strings.Index(e, "="); idx > 0 {
|
|
key := e[:idx]
|
|
if !strings.HasPrefix(key, "AGENTCTL_") && !isNpmEnvVar(key) {
|
|
envMap[key] = e[idx+1:]
|
|
}
|
|
}
|
|
}
|
|
|
|
// Merge additional env vars
|
|
for k, v := range additional {
|
|
envMap[k] = v
|
|
}
|
|
|
|
// Convert back to slice
|
|
result := make([]string, 0, len(envMap))
|
|
for k, v := range envMap {
|
|
result = append(result, k+"="+v)
|
|
}
|
|
return result
|
|
}
|
|
|
|
// isNpmEnvVar returns true if the key is an npm-related environment variable
|
|
// that should be filtered out to prevent warnings in npx commands.
|
|
func isNpmEnvVar(key string) bool {
|
|
return strings.HasPrefix(key, "npm_config_") ||
|
|
strings.HasPrefix(key, "npm_package_") ||
|
|
strings.HasPrefix(key, "npm_lifecycle_") ||
|
|
strings.HasPrefix(key, "npm_execpath") ||
|
|
strings.HasPrefix(key, "npm_node_execpath")
|
|
}
|
|
|
|
func getEnv(key, defaultValue string) string {
|
|
if value := os.Getenv(key); value != "" {
|
|
return value
|
|
}
|
|
return defaultValue
|
|
}
|
|
|
|
// getEnvWithFallback checks the primary key, then the fallback key, then returns defaultValue.
|
|
func getEnvWithFallback(primary, fallback, defaultValue string) string {
|
|
if value := os.Getenv(primary); value != "" {
|
|
return value
|
|
}
|
|
if value := os.Getenv(fallback); value != "" {
|
|
return value
|
|
}
|
|
return defaultValue
|
|
}
|
|
|
|
func getEnvInt(key string, defaultValue int) int {
|
|
if value := os.Getenv(key); value != "" {
|
|
if i, err := strconv.Atoi(value); err == nil {
|
|
return i
|
|
}
|
|
}
|
|
return defaultValue
|
|
}
|
|
|
|
func getEnvInt64(key string, defaultValue int64) int64 {
|
|
if value := os.Getenv(key); value != "" {
|
|
if i, err := strconv.ParseInt(value, 10, 64); err == nil {
|
|
return i
|
|
}
|
|
}
|
|
return defaultValue
|
|
}
|
|
|
|
func getEnvBool(key string, defaultValue bool) bool {
|
|
if value := os.Getenv(key); value != "" {
|
|
return strings.ToLower(value) == "true" || value == "1"
|
|
}
|
|
return defaultValue
|
|
}
|
|
|
|
// getEnvDuration parses a duration value from an env var. Accepts any value
|
|
// time.ParseDuration accepts (e.g. "1h", "30m", "500ms"). "0" disables the
|
|
// feature. Invalid or missing values return defaultValue.
|
|
func getEnvDuration(key string, defaultValue time.Duration) time.Duration {
|
|
value := os.Getenv(key)
|
|
if value == "" {
|
|
return defaultValue
|
|
}
|
|
d, err := time.ParseDuration(value)
|
|
if err != nil {
|
|
return defaultValue
|
|
}
|
|
return d
|
|
}
|
|
|
|
// kandevMcpServerName is the name used for the local kandev MCP server.
|
|
const kandevMcpServerName = "kandev"
|
|
|
|
// injectKandevMcpServer prepends the local kandev MCP server to the list of MCP servers.
|
|
// This replaces any existing kandev server to avoid duplicates.
|
|
// The kandev MCP server provides tools like ask_user_question to the agent.
|
|
// Both HTTP and SSE variants are injected - agent capability filtering will select the
|
|
// appropriate one. HTTP is listed first so that when an agent advertises both transports
|
|
// the "first surviving entry wins" dedup keeps the HTTP entry (modern streamable MCP);
|
|
// SSE remains as a fallback for SSE-only agents.
|
|
func injectKandevMcpServer(servers []McpServerConfig, port int) []McpServerConfig {
|
|
portStr := strconv.Itoa(port)
|
|
kandevMcpSse := McpServerConfig{
|
|
Name: kandevMcpServerName,
|
|
Type: "sse",
|
|
URL: "http://localhost:" + portStr + "/sse",
|
|
}
|
|
kandevMcpHttp := McpServerConfig{
|
|
Name: kandevMcpServerName,
|
|
Type: "http",
|
|
URL: "http://localhost:" + portStr + "/mcp",
|
|
}
|
|
|
|
// Filter out any existing kandev server and prepend the local ones
|
|
result := make([]McpServerConfig, 0, len(servers)+2)
|
|
result = append(result, kandevMcpHttp, kandevMcpSse)
|
|
for _, srv := range servers {
|
|
if srv.Name != kandevMcpServerName {
|
|
result = append(result, srv)
|
|
}
|
|
}
|
|
return result
|
|
}
|