* fix(auth): scope session-keyed reads and mutations to the workspace owner Session-keyed service entry points did not apply per-user scoping, so knowing another user's session or task ID was enough to reach it: - GetTaskSession, ListTaskSessions, GetPrimarySession returned rows for any ID. These feed GET /task-sessions/:id and GET /tasks/:id/sessions, whose DTO carries session Metadata, the host worktree path, the branch, and container/executor IDs. - DismissLastAgentError mutated any session. - ApproveSession approved a workflow review step on any session, which advances the task's workflow — the highest-impact of the set. Authorize via authorizeTaskID off the row already read, rather than AuthorizeSessionAccess, which would re-read the session on what is a hot path. Internal identity-less callers (pollers, event bus, office schedulers, office agent JWTs) and the synthetic identity used while auth is disabled stay unscoped, and unowned pre-auth rows stay visible. Batch helpers that take ID lists are deliberately left alone: their callers derive IDs from an already-authorized task list, so per-ID checks would add N queries to list views for no gain. Recorded in AGENTS.md. Also route httpApproveSession through handleNotFound so a denial answers 404 like every other session route instead of a 500 that would confirm the session exists. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(auth): scope the session-keyed paths that bypass the task service An adversarial sweep of the previous commit found session-keyed surfaces reachable with a caller-supplied ID and no ownership check. They bypass the task service, so scoping its methods did not cover them: - POST /task-sessions/:id/{set-mode,set-model,set-config-option, authenticate} and .../processes/:processId/stop resolve their execution with a bare in-memory lookup, which skips the GetOrEnsure* chokepoint where the lifecycle per-user check runs. A non-owner could switch another user's model or mode, re-authenticate their agent, or kill their process. Guarded by a shared denySessionAccess helper. - The orchestrator reads sessions through its own repo handle, so GetTaskSessionStatus and CheckSessionPR inherited nothing from the task service. CheckSessionPR also installs a PR watch as a side effect. Added a SetSessionAccessChecker hook mirroring the lifecycle manager's, wired to taskSvc.AuthorizeSessionAccess. The check runs before CheckSessionPR's unconfigured-GitHub early return so it applies regardless of setup, and denials reuse the "session not found" shape so they cannot be told apart from a missing session. - GetMessage had no scoping while ListMessages beside it did, so a caller holding another user's (session_id, message_id) pair could read their shell output. This does NOT close the whole surface. A second sweep found the orchestrator's session-mutating WS actions (session.stop/delete/rename/ set_primary/set_plan_mode/reset_context/recover/launch/ensure, agent.cancel, permission.respond) and agent.stdin / agent.resize also unscoped — agent.stdin writes arbitrary input into another user's agent process. Those touch live session lifecycle and need their own change; recorded in AGENTS.md so the gap is not mistaken for covered. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(auth): scope every session-keyed WS action, plus a gateway backstop Completes the sweep. Dispatched WS actions were left to authorize themselves at the service layer, and a dozen never did — a caller with a guessed session ID could stop, delete, rename, relaunch or reset another user's live agent session, cancel their in-flight turn, answer their permission prompts, or write arbitrary bytes into their agent process. Two layers, deliberately both: 1. Gateway backstop. Client.authorizeAction checks any dispatched payload carrying task_id or session_id against the client's identity before dispatch. Per-method opt-in across ~74 actions in four packages had been missed repeatedly; this makes a newly added action scoped by default. Verified safe to apply generically: session_id means a task session everywhere, and user-shell/terminal actions key off task_id / task_environment_id, so nothing is misread. 2. Explicit guards, for defense in depth and for non-WS callers: - orchestrator: StopSession, DeleteSession, SetPrimarySession, RenameSession, ResetAgentContext, CancelAgent, RespondToPermission, SetSessionPlanModeByID, RecoverSession, LaunchSession, EnsureSession, CancelTransientRetry. Added SetTaskAccessChecker for the task-keyed ones. LaunchSession guards every intent at the single funnel; SessionID is empty on create, so the task check carries that case. - agent.stdin / agent.resize now call lifecycleMgr.CheckSessionAccess, the helper already documented for bare-lookup handlers. Guards go first in each method, before any repo/executor use. TestSessionKeyedEntryPointsGuardBeforeDependencies runs all twelve with nil dependencies and fails on a panic if one is placed too late. Denials are indistinguishable from a missing resource: WS returns not-found, CancelTransientRetry reports "nothing cancelled", and GetTaskSessionStatus reuses its "session not found" shape. Unwired checkers stay permissive throughout, so identity-less internal callers (schedulers, event bus, workflow engine) and pre-auth single-user instances are unaffected — pinned per entry point. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(auth): scope environment-keyed user-shell actions Third sweep finding, in surface the previous commit's own comment claimed was covered. The user-shell actions key off task_environment_id and treat task_id as optional, so a task_id-only backstop missed them: user_shell.stop with an empty task_id and a foreign task_environment_id fell through to the legacy runner path and tore down another user's terminal PTY. That path never reaches GetOrEnsureExecutionForEnvironment, where the environment check normally runs, so neither layer caught it. - Gateway backstop now also reads task_environment_id, gated by a new AuthPolicy.ActionEnvironment hook wired to taskSvc.AuthorizeEnvironmentAccess. This covers all three environment-keyed actions generically, not just the reported one. - wsUserShellStop guards explicitly via a new lifecycleMgr.CheckEnvironmentAccess, mirroring CheckSessionAccess, so the fallback is safe independent of the gateway. Corrected the comment that wrongly asserted task_environment_id was already covered, and recorded in AGENTS.md both the exact field names the backstop reads and why a permissive type on one of them would silently opt out. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(auth): authorize both IDs on task+session entry points Review finding (P1). Entry points taking both a task and a session ID only checked the session, so a caller could satisfy that check with one of their own sessions while pointing taskID at another user's task: - CheckSessionPR then used the unchecked taskID for GetTaskPR, resolveTaskRepo, resolvePrimaryTaskRepositoryID and EnsurePRWatch — cross-workspace PR disclosure plus a PR-watch write. - CancelTransientRetry handed it to handleRecoverableFailure, which writes against that task. Both now use a new authorizeTaskSessionPair, which authorizes each ID and additionally requires the pair to be consistent, so the two arguments cannot describe different tasks. GetTaskSessionStatus already validated the pair after loading the session and is unchanged. The consistency half is skipped when the session cannot be loaded: both IDs are authorized by then, so a mismatch is a caller bug rather than a cross-user leak, and failing there would change behavior for identity-less internal callers. Recorded the rule in AGENTS.md — a two-ID entry point must never use authorizeSession alone. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(auth): scope AttachWorkspaceSources Found while rebasing onto main. POST /tasks/:id/workspace-sources and the add_workspace_sources_kandev MCP tool landed on main during this PR's review with no ownership check, so a caller could attach repositories and folders to another user's task and have them materialized into that task's workspace. Same class as the rest of this PR, so fixed here rather than left for a follow-up. The WS dispatch backstop added earlier already covered the browser path (the payload carries task_id); this closes the HTTP route and the MCP tool, which do not go through it. ErrTaskNotFound already maps to 404 in workspaceSourceHTTPStatus, so the denial surfaces correctly without touching the handler. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(auth): map denied shell-output reads to 404 Review finding. GetMessage became per-user scoped earlier in this PR, so a denial now arrives as ErrTaskNotFound. httpGetShellOutput only mapped sql.ErrNoRows to 404, sending everything else to the 500 branch — so a foreign message answered 500 while a missing one answered 404, which is an existence signal and inconsistent with the 404-everywhere rule the other session routes follow. Kept the sql.ErrNoRows branch explicit rather than replacing it outright: isNotFound classifies typed sentinels and the "not found" substring, and "sql: no rows in result set" matches neither, so routing that case through handleNotFound alone regressed the missing-message response to 500 (caught by TestHTTPShellOutputSnapshot). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(auth): make the shell-output scoping test actually exercise the guard Both reviewers caught that TestHTTPGetShellOutputDeniesForeignMessageWith404 was vacuous. httpGetShellOutput has a second 404 gate after the service call — `if !ok || message.TaskSessionID != c.Param("id")` — and ExtractShellExecOutput returns ok=false for nil metadata. The fixture had none, so the route answered 404 through that gate whether or not scoping denied the read. Confirmed by mutation rather than inspection: with the authorization removed from GetMessage the test still passed. With valid shell-output metadata in the fixture it returns 200 and the victim's stdout, so the test fails as intended; restoring the guard makes it pass again. Also mutation-tested the sibling denial tests, since this is a defect class rather than a one-off: - TestHTTPGetTaskSessionDeniesForeignSessionWith404 — sound, returns 200 with the session DTO when the guard is removed. - TestProcessRoutesDenyForeignSession — catches removal, though via the documented nil-lifecycleMgr panic rather than a status assertion. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(auth): add an in-test witness to the shell-output scoping test Review suggestion. The fixture fix made the test exercise the guard, but nothing in the test proved the 404 came from authorization rather than the route's post-service `!ok` gate — which is exactly how it went vacuous in the first place. The evidence lived in a commit message describing a manual mutation run, not in the test. Now asserts both directions, matching the approve-route test: the non-owner gets 404, and the owner does NOT. If the assertion ever stops proving authorization, the owner case fails and says so. Re-verified by mutation: removing the guard from GetMessage fails the test with 200 and the victim's stdout; restoring it passes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Kandev Agent <agent@kandev.dev> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
30 KiB
Backend (Go) — architecture and conventions
Scoped guidance for apps/backend/. Repo-wide rules (commit format, code-quality limits, etc.) live in the root AGENTS.md.
Package Structure
apps/backend/
├── cmd/
│ ├── kandev/ # Main backend binary entry point
│ ├── agentctl/ # Agentctl binary (runs inside containers or standalone)
│ └── mock-agent/ # Mock agent for testing
├── internal/
│ ├── agent/
│ │ ├── runtime/ # Agent runtime: single seam for Launch/Resume/Stop/observe
│ │ │ ├── lifecycle/ # Agent instance management (moved from agent/lifecycle)
│ │ │ ├── agentctl/ # HTTP client for talking to agentctl (moved from agentctl/client)
│ │ │ └── routingerr/ # Provider error classifier + sanitizer + ProviderProber registry
│ │ ├── agents/ # Agent type implementations
│ │ ├── controller/ # Agent control operations
│ │ ├── credentials/ # Agent credential management
│ │ ├── discovery/ # Agent discovery
│ │ ├── docker/ # Docker-specific agent logic
│ │ ├── dto/ # Agent data transfer objects
│ │ ├── executor/ # Executor types, checks, and service
│ │ ├── handlers/ # Agent event handlers
│ │ ├── registry/ # Agent type registry and defaults
│ │ ├── settings/ # Agent settings
│ │ ├── mcpconfig/ # MCP server configuration
│ │ └── remoteauth/ # Remote auth catalog and method IDs for remote executors/UI
│ ├── auth/ # Opt-in user authentication + per-user scoping
│ │ ├── authn/ # Request-identity plumbing (context + gin helpers, RequireAdmin)
│ │ ├── httpmw/ # Global enforcement middleware (allowlist, synthetic identity)
│ │ ├── httpapi/ # /api/v1/auth/* + /api/v1/users endpoints
│ │ └── store/ # auth_identities, auth_sessions, auth_api_tokens, auth_invites
│ ├── agentctl/
│ │ └── server/ # agentctl HTTP server
│ │ ├── acp/ # ACP protocol implementation
│ │ ├── adapter/ # Protocol adapters + transport/ (ACP, Codex, OpenCode, Copilot, Amp)
│ │ ├── api/ # HTTP endpoints
│ │ ├── config/ # agentctl configuration
│ │ ├── instance/ # Multi-instance management
│ │ ├── mcp/ # MCP server integration
│ │ ├── process/ # Agent subprocess management
│ │ ├── shell/ # Shell session management
│ │ └── utility/ # agentctl utilities
│ ├── orchestrator/ # Task execution coordination
│ │ ├── dto/ # Orchestrator data transfer objects
│ │ ├── executor/ # Launches agents via lifecycle manager
│ │ ├── handlers/ # Orchestrator event handlers
│ │ ├── messagequeue/ # Message queue for agent prompts
│ │ ├── queue/ # Task queue
│ │ ├── scheduler/ # Task scheduling
│ │ └── watcher/ # Event handlers
│ ├── task/
│ │ ├── controller/ # Task HTTP/WS controllers
│ │ ├── dto/ # Task data transfer objects
│ │ ├── events/ # Task event types
│ │ ├── handlers/ # Task event handlers
│ │ ├── models/ # Task, Session, Executor, Message models
│ │ ├── repository/ # Database access (SQLite)
│ │ └── service/ # Task business logic
│ ├── office/ # Autonomous agent management (Office feature)
│ │ ├── agents/ # Agent instance CRUD + auth guards
│ │ ├── approvals/ # Approval requests and decisions
│ │ ├── channels/ # External integration channels (webhooks)
│ │ ├── config/ # Config sync (DB ↔ filesystem)
│ │ ├── configloader/ # Filesystem config reader/writer
│ │ ├── costs/ # Cost tracking and budget policies
│ │ ├── dashboard/ # Dashboard API, issues, activity, live runs
│ │ ├── infra/ # GC, reconciliation
│ │ ├── labels/ # Task labels
│ │ ├── onboarding/ # Workspace onboarding wizard API
│ │ ├── projects/ # Project management
│ │ ├── repository/ # Office SQLite persistence
│ │ ├── runtime/ # Agent run context, capabilities, and runtime action surface
│ │ ├── routines/ # Scheduled recurring tasks
│ │ ├── routing/ # Provider routing: resolver, validators, catalogue, backoff, agent-overrides
│ │ ├── scheduler/ # Wakeup scheduler (duplicate of service scheduler features)
│ │ ├── service/ # Core office service (wakeups, event subscribers, execution policy)
│ │ ├── shared/ # Shared interfaces and activity logging
│ │ ├── skills/ # Skill injection and materialization
│ │ └── workspaces/ # Workspace deletion handler
│ ├── events/ # Event bus for internal pub/sub
│ ├── gateway/ # WebSocket gateway
│ ├── github/ # GitHub API integration (PRs, reviews, webhooks)
│ ├── githubauth/ # Shared GitHub credential-broker environment contract
│ ├── common/ # Shared utilities, config, logger
│ ├── integration/ # External integrations
│ ├── integrations/ # Shared shapes for third-party integrations
│ │ ├── healthpoll/ # Reusable 90s auth-health Poller (used by jira, linear)
│ │ └── secretadapter/ # Upsert-style adapter over secrets.SecretStore
│ ├── jira/ # Jira/Atlassian Cloud integration (config, REST client, poller)
│ ├── linear/ # Linear integration (config, GraphQL client, poller)
│ ├── lsp/ # LSP server
│ ├── mcp/ # MCP protocol support
│ ├── health/ # Health check endpoints
│ ├── notifications/ # Notification system
│ ├── persistence/ # Persistence layer
│ ├── prompts/ # Prompt management
│ ├── repoclone/ # Repository cloning for remote executors
│ ├── scriptengine/ # Script placeholder resolution and interpolation
│ ├── secrets/ # Secret management
│ ├── sprites/ # Sprites AI integration
│ ├── sysprompt/ # System prompt injection
│ ├── tools/ # Tool integrations
│ ├── user/ # User management
│ ├── utility/ # Shared utility functions
│ ├── workflow/ # Workflow engine
│ │ ├── engine/ # Typed state-machine engine
│ │ ├── models/ # Workflow step, template, and history models
│ │ ├── repository/ # Workflow persistence (SQLite)
│ │ └── service/ # Workflow CRUD, step resolution, and sync apply
│ ├── workflowsync/ # GitHub workflow sync (per-workspace repo config, poller, force sync)
│ └── worktree/ # Git worktree management for workspace isolation
Key Concepts
Orchestrator coordinates task execution:
- Receives task start/stop/resume requests via WebSocket
- Delegates to lifecycle manager for agent operations
- Handles event-driven state transitions via workflow engine
- Located in
internal/orchestrator/
Watcher Dispatch Coordinator (internal/orchestrator/watcher_dispatch.go) is the single pipeline that turns a freshly-observed external issue (Linear, Jira, future) into a Kandev task. Bus subscribers for each integration forward the event to WatcherDispatchCoordinator.Dispatch with a per-integration WatcherSource implementation (source_linear.go, source_jira.go). Source methods carry the integration-specific bits (reserve dedup, build task request, attach task ID, release, auto-start params); the coordinator owns the cross-cutting pipeline (create task, decide auto-start, error/release handling). Add a new watcher = implement WatcherSource + register a one-line bus subscriber. Do NOT add another createXIssueTask mirror.
GitHub App registration catalog (internal/github/) stores zero or more managed/imported App
registrations; none is a global default. Workspace App connections must carry both registration ID
and installation ID. Runtime clients, token caches, broker leases, OAuth state, webhook delivery,
and service cache scopes must retain registration identity and credential generation. Reusing a
registration intentionally shares root App credentials and bot identity; installation grants and
workspace credentials remain isolated. Registration create/import/select/install belongs to the
workspace GitHub settings flow, and backend startup configuration is not an App credential source.
Registration-specific callback and webhook routes select one candidate registration but never
replace state verification, installation association, or HMAC verification.
Workflow Engine (internal/workflow/engine/) provides typed state-machine evaluation:
Engine.HandleTrigger()evaluates step actions for triggers (on_enter, on_turn_start, on_turn_complete, on_exit)TransitionStoreinterface abstracts persistence (implemented byorchestrator.workflowStore)CallbackRegistrymaps action kinds to callbacks (plan mode, auto-start, context reset)- First-transition-wins: multiple transition actions in one trigger, first eligible wins
EvaluateOnlymode: engine evaluates without persisting, caller orchestrates on_exit → DB → on_enterRequiresApprovalon actions: transitions requiring review gating are skipped- Idempotent by
OperationID; session-scoped data bag viaMachineState.Data
Agent Runtime (internal/agent/runtime/) is the single seam for launching, resuming, stopping, and observing agent executions. ADR 0004 introduced this in Phase 1 of task-model-unification. The public surface is runtime.Runtime (runtime.go); a thin facade (facade.go) delegates to a Backend (satisfied by *lifecycle.Manager).
Convention: only internal/agent/runtime/ (and code that pre-dates Phase 1 migration) may import runtime/lifecycle or runtime/agentctl directly. New consumers — workflow engine actions, cron-driven trigger handlers, future task-tier callers — should depend on runtime.Runtime. Existing call sites are migrated through later phases of task-model-unification.
Lifecycle Manager (internal/agent/runtime/lifecycle/) manages agent instances under the runtime:
Manager(manager.go,manager_*.go) - central coordinator for agent lifecycleExecutorBackendinterface (executor_backend.go) - abstracts execution environment (Docker, Standalone, Sprites, Remote Docker)ExecutionStore(execution_store.go) - thread-safe in-memory execution trackingsession.go- ACP session initialization and resumestreams.go- WebSocket stream connections to agentctlprocess_runner.go- agent process launch and managementprofile_resolver.go- resolves agent profiles/settings
agentctl client (internal/agent/runtime/agentctl/) is the HTTP/WS client used by the lifecycle manager to talk to a running agentctl instance. It is a runtime-tier package and should not be imported outside internal/agent/runtime/.
agentctl is an HTTP server that:
- Runs inside Docker containers or as standalone process
- Manages agent subprocess via stdin/stdout (ACP protocol)
- Exposes workspace operations (shell, git, files)
- Supports multiple concurrent instances on different ports
Standalone agentctl is launched in its own process group so terminal Ctrl+C is handled by the backend lifecycle manager first. Do not make standalone agentctl share the backend's foreground process group; that bypasses supervised shutdown and can leak ACP subprocesses.
Executor Types (database model):
local_pc- Standalone process on hostlocal_docker- Docker container on hostsprites- Sprites cloud environmentremote_docker,remote_vps,k8s- Planned
Remote SSH executor platforms: Treat supported remote OS/arch values as an end-to-end contract. Platform probe/normalization, lifecycle support checks, agentctl helper resolution, platform default shell, SSH readiness endpoints, frontend response types, and tests must stay aligned. Preserve raw unsupported platform details in user-facing errors, but use normalized values for supported-platform matching. Keep shell defaults platform-aware: Darwin defaults to zsh, Linux defaults to bash, unless an explicit shell is saved.
Opt-in authentication & per-user scoping (internal/auth/): auth is OFF by default; the global middleware (auth/httpmw, installed after CORS in backendapp.buildHTTPServer) then injects a synthetic admin identity for the pre-auth single user, so behavior is unchanged. Enablement is the features.auth runtime flag (KANDEV_FEATURES_AUTH, Settings > System > Feature Toggles) — the auth service derives its mode from cfg.Features.Auth (disabled / setup = flag-on-no-admin / enabled = flag-on-admin-exists); there is no separate auth.mode setting. When enabled, requests authenticate via a kandev_session cookie (opaque token, SHA-256 at rest, DB-backed) or a kandev_pat_* bearer token. Scoping rules:
- Identity travels in the request context (
authn.IdentityFromContext). No identity = internal caller (pollers, event bus, office schedulers) = unscoped. Synthetic identity = auth disabled = unscoped. - In-session agent MCP is scoped to the task owner. The MCP tools an agent gets inside its own session are relayed over the agent's WebSocket stream, which carries no credential of its own.
internal/mcp/scoperesolves the stream's task → workspace → owner and attaches that user's real identity before dispatch (lifecycle.Manager.SetMCPIdentityScoper), so the sameauthorize*checks apply as for the PAT-authenticated/mcpendpoint. The owning task comes from theAgentExecution, never from the agent-supplied payload — do not "improve" this by readingsession_id/task_idout of the request. Tool handlers stay identity-agnostic. Under enforced auth every dispatch is scoped to somebody: a resolvable active owner gets their identity; an unowned workspace gets a sentinel user ID that reaches unowned rows only; anything unresolvable (missing task/workspace row, or an owner whose account was deleted or disabled) is denied. Never return an identity-free context from this path — the task service reads that as an internal caller and grants everything. - Workspaces are per-user (
workspaces.owner_id); the task service filters/denies at the service layer with*NotFoundsentinels (no existence leak). Unowned rows (owner_id='') stay visible until the setup wizard claims them for the admin. - New user-facing service entry points must apply scoping — call the
authorize*helpers intask/service/service_access.go(or the same pattern) when adding routes that read or mutate workspace-scoped data. This includes session-keyed entry points: any service method taking a caller-suppliedsessionID(or ataskIDused to reach sessions) authorizes viaauthorizeTaskID(session.TaskID). Authorize off the row you already read rather than callingAuthorizeSessionAccess, which re-reads the session. Batch helpers that take ID lists (BatchGetSessionsForTasks,GetPrimarySessionInfoForTasks, …) are exempt by convention: their callers derive the IDs from an already-authorized task list, so per-ID checks would add N queries to list views for no gain — keep it that way, and don't hand them caller-supplied IDs. - Two session paths bypass the task service and must guard themselves. (1) Handlers that resolve an execution by a bare in-memory lookup (
GetExecutionBySessionID,*BySessionID) skip theGetOrEnsure*chokepoint where the lifecycle check runs — callservice.AuthorizeSessionAccess(seeProcessHandlers.denySessionAccess) orlifecycleMgr.CheckSessionAccessfirst. (2) The orchestrator reads sessions through its own repo handle, so it inherits nothing from the task service; session-keyed entry points there callauthorizeSession/authorizeTask, wired bySetSessionAccessChecker/SetTaskAccessChecker. An entry point that accepts both a task and a session ID must useauthorizeTaskSessionPair, neverauthorizeSessionalone: the caller can satisfy a session-only check with one of their own sessions while pointingtaskIDat someone else's task, which the method then uses for its task-scoped work. Put the guard first in the method, before any repo/executor/agent-manager use —TestSessionKeyedEntryPointsGuardBeforeDependenciesruns each entry point with nil dependencies and fails on a panic if a guard is placed too late. - Dispatched WS actions have a gateway backstop.
Client.authorizeAction(internal/gateway/websocket/dispatch_scope.go) checks any payload carryingtask_idorsession_idagainst the client's identity before dispatch, so a newly added action is scoped by default rather than only when its author remembers. Handler-level scoping is still required (defense in depth, and it covers non-WS callers) — do not delete a service-layer check because the backstop exists. The backstop readstask_id,session_idandtask_environment_id— keep using those names for task-scoped actions, because an action that invents a new name for the same kind of resource silently opts out. (user_shell.stopwas exactly that: it keys offtask_environment_idwithtask_idoptional, so atask_id-only backstop missed it.) Also avoid permissive types (any,json.RawMessage) for those fields: a payload that fails to unmarshal into the ref struct is treated as naming nothing. - WS: clients carry their identity; dispatched actions and subscriptions are scoped; workspace-carrying events route via
Hub.BroadcastToWorkspace. A newhub.Broadcast(global) call site needs a//ws:globaljustification comment. - Self-authenticating webhooks (automation, office channels, plugin webhooks) and
/health,/api/v1/features,/api/v1/app-statestay public — the allowlist lives inauth/httpmw/middleware.gowith a pinning test.
Execution Flow
Client (WS) → Orchestrator → Lifecycle Manager → ExecutorBackend (container/process) → agentctl
↓
Client (WS) ← Orchestrator ← Lifecycle Manager ←──── stream updates (WS) ──────── agent subprocess
- Orchestrator receives
session.launchvia WS - Lifecycle Manager creates executor instance (container or process)
- agentctl starts inside the instance, agent subprocess is configured and started
- Agent events stream back via WS through the chain
Session Resume: TaskSession.ACPSessionID stored for resume; ExecutorRunning tracks active state; on restart RecoverInstances() reconnects.
Provider Pattern: Packages expose Provide(cfg, log) (*impl, cleanup, error) for DI. Returns implementation, cleanup function, and error. Cleanup called during graceful shutdown.
Worktrees: internal/worktree/Manager provides workspace isolation. Each session can have its own worktree (branch) to prevent conflicts between concurrent agents.
Worktree file materialization: copy_files (Repository.CopyFiles) is a comma-separated spec of repository-relative gitignored paths/doublestar globs seeded into each new worktree. Copy is the default; the exact terminal :symlink suffix (for example .env.local:symlink) creates a relative host-worktree link so source changes propagate live. Other colons stay literal (config:dev, .env:); use ::symlink to copy a literal path ending in :symlink. Malformed reserved syntax is rejected at save time. Parsing and materialization live in internal/worktree/copyfiles/ (ParseSpecs, ValidateSpec, Copy); duplicates and overlapping matches are first-entry-wins. Manager.copyConfiguredFiles runs before setup during worktree creation. Source and destination containment checks reject traversal and symlinked destination parents. Failures are non-fatal warnings. Windows link creation is best-effort. Remote executors cannot link to the host, so Parse/Plan preserve literal colon paths but turn symlink-mode entries into copied bytes delivered through WriteEntries.
Executor default scripts: Default prepare scripts are in internal/agent/runtime/lifecycle/default_scripts.go; internal/scriptengine/ handles placeholder resolution.
Conventions
- Provider pattern for DI; stderr for logs, stdout for ACP only.
- Pass context through chains; event bus for cross-component comm.
- Repository provider identity: Provider-backed repositories are keyed by workspace, provider, normalized
provider_hostorigin, full owner/namespace, and name. Persistprovider_hostwhen importing or resolving a remote; do not infer self-managed GitLab rows from owner/name alone. Legacy rows with an empty host have unknown identity and must fail closed for provider write/link operations. - Execution access: Workspace-oriented handlers (files, shell, inference, ports, vscode, LSP) MUST use
GetOrEnsureExecution(ctx, sessionID)— it recovers from backend restarts by creating executions on-demand. Only useGetExecutionBySessionIDfor operations that require a running agent process (prompt, cancel, mode). - Task lifecycle events: Any code path that mutates a task row must publish via the event bus (
task.created/task.updated/task.deleted) — either by going throughService.CreateTask/UpdateTask/DeleteTask/ArchiveTask, or by callingpublishTaskEvent(or one of thePublish*helpers inservice_events.go) directly. Walkingrepository.TaskRepositorystraight bypasses event publishing and breaks WS-driven UI like the All-Workflows kanban view.HandoffService's cascade methods learned this the hard way — they now require aTaskEventPublisherwired viaSetTaskEventPublisher. New cascade / bulk / cleanup paths must follow the same pattern. - Testing: Prefer
testing/synctest(Go 1.24+) overtime.Sleepfor time-dependent tests. Usesynctest.Testto wrap tests with tickers or timeouts — it advances fake time instantly when all goroutines are idle. Whensynctestis not feasible (e.g., tests spawning external processes likegit), use channel-based synchronization (<-started, non-blockingselect) instead of sleep-based waits. Reservetime.Sleeponly for integration tests that need real subprocess execution time.- Test cleanup: Register
t.Cleanupimmediately after creating resources that need teardown (adapters,io.Pipewriters, background goroutines) — before anyt.Fatal/t.Fatalfpath. Late cleanup registration leaks pipes and goroutines on early failure. - Joining production goroutines in tests: When code spawns untracked goroutines (e.g.
fireWakeup), don't rely on arbitrary sleeps. Join via an observable side effect — e.g. block onEventTypeCompletefroma.updatesChafter unblocking the fake agent. Use short timeouts (~100ms) for in-process negative assertions; reserve multi-second waits for subprocess/integration tests only. - Path/security tests: Avoid using the real filesystem root as a fixture root. Build fake absolute roots under
t.TempDir()withfilepath.Join; this keeps tests portable across Windows, POSIX, and privileged cloud executors. - Filesystem permission tests: Assert permission-denied behavior only after probing that the current executor enforces the permission bit change. Root-like Sprite executors may bypass
chmodrestrictions. - Full test output: For local full-suite pass/fail validation, prefer plain
go test -race ./....go test -json ./...can emit very large JSONL streams; if a wrapper or tracing tool truncates the stream mid-record, downstream JSON parsing may fail even when Go tests passed. Use JSON output mainly for CI artifacts or test-report tooling that explicitly requires it.
- Test cleanup: Register
Goroutine ownership and leak testing
Every long-running goroutine must have a single owner with explicit start and stop semantics:
- Lifecycle: the type that spawns the goroutine also exposes
Start(ctx)/Stop()(or equivalent).Startregisters on async.WaitGroup;Stopcancels the goroutine's context (or closes astopCh) andwg.Wait()s for drain. Idempotent on both ends.internal/integrations/healthpoll,internal/jira,internal/linear, andinternal/githubpollers are the canonical shape. - E2E reset invariant:
seedData/backend are worker-scoped, so any workspace-scoped state a global poller reads (for examplegithub_review_watches) must be deleted incmd/kandev/e2e_reset.gobefore task deletion — otherwise the poller recreates rows mid-reset and later tests see duplicates. Add aDelete...ByWorkspacecascade when introducing a new poller-backed entity. - Cancellation: the goroutine selects on
ctx.Done()(orstopCh) in every long wait. Never usetime.Sleepin a retry/backoff loop — usetime.NewTimerinside aselectthat also watches the shutdown signal (seelifecycle.StreamManager.sleepOrStop). - Detached helpers: event handlers and short-lived
go func()calls ininternal/orchestrator/andinternal/agent/runtime/lifecycle/must accept a cancellable context (or check the owning type's shutdown signal) and return promptly when it fires. - Leak testing: packages that spawn goroutines add
goleak.VerifyTestMain(m)in a per-packageTestMain. New packages of this kind must follow suit. When a third-party background goroutine genuinely can't be drained, suppress it withgoleak.IgnoreTopFunction(...)and leave a comment explaining why. Currently instrumented:internal/gateway/websocket/,internal/agent/runtime/lifecycle/,internal/agentctl/server/process/,internal/orchestrator/,internal/github/,internal/jira/,internal/linear/,internal/integrations/healthpoll/.
Backups
- On every SQLite boot,
persistence.Providereadskandev_meta.kandev_version. If the stored version differs from the binary version (or any user tables exist but no version is recorded), it takes aVACUUM INTOsnapshot into<data-dir>/backups/before running migrations. - Retention: 2 backups kept (newest two by mtime); older ones are pruned after the snapshot succeeds.
- Postgres: backup is skipped with a log line. Use
pg_dumpfor Postgres backups. - Boot aborts if the backup fails — the pool is closed and
Providereturns an error. - After all repos complete
initSchema,cmd/kandev/storage.go:recordSchemaVersionwrites the current binary version intokandev_meta(non-fatal; a failure just means the next boot will take a fresh snapshot). - Migration logging:
db.MigrateLogger.Apply(name, stmt)— success logs Info, "already exists" / "duplicate column name" is silently swallowed, anything else logs Warn but never returns an error (preserving the existing swallow-error contract). - Schema replay handling: use
internal/dbhelpers such asIsDuplicateColumnError/IsAlreadyExistsErrorinstead of local error-string matching. When adding or changing startup schema code, include fresh-DB plus same-DB replay tests for SQLite; add the same env-gated Postgres replay coverage when the path supports Postgres. Seedocs/decisions/0027-replayable-schema-migrations.md.
Schema & migrations (SQLite repository)
initSchema() in internal/task/repository/sqlite/base_schema.go runs the init*Schema (CREATE TABLE) steps before runMigrations(). The table-creation DDL uses CREATE TABLE IF NOT EXISTS, so on an existing database it is a no-op and never adds columns to a table that is already present.
Rule: when you add a column to an existing table, add it only via an idempotent ADD COLUMN migration in runMigrations() (base_migrations.go), never by editing the table's CREATE TABLE alone. Anything that references that new column — an index, a backfill UPDATE, a partial-index predicate — must live in runMigrations() after the ADD COLUMN, not in the init*Schema DDL. Putting a CREATE INDEX ... (new_col) in the schema-init block crashes existing DBs with no such column: new_col, because schema init runs before the migration that adds the column.
You may still list the column in the CREATE TABLE so fresh DBs get it inline, but the migration is the source of truth for evolution and must stand alone. New columns also need: the struct field in models/, the DTO field + ToAPI in pkg/api/v1/, and every CreateX/UpdateX/bulk write in the repo that should set it.
Code-quality limits
Enforced by apps/backend/.golangci.yml (errors on new code only):
- Functions: ≤80 lines, ≤50 statements
- Cyclomatic complexity: ≤15 · Cognitive complexity: ≤30
- Nesting depth: ≤5 · Naked returns only in functions ≤30 lines
- No duplicated blocks (≥150 tokens) · Repeated strings → constants (≥3 occurrences)
When you hit a limit, extract a helper function. Prefer composition over growing a single function.
When a PR fixup touches backend code, run the CI-style changed-file linter locally from apps/backend with the PR base SHA before pushing, because CI enforces changed-file complexity thresholds:
golangci-lint run ./... --new-from-rev="<base-sha>" --timeout=5m
Further scoped notes
internal/agentctl/AGENTS.md— agentctl server route groups, adapter model, ACP protocolinternal/agentctl/server/api/AGENTS.md— reverse-proxy body rewriting (Accept-Encoding), iframe-blocking header strippinginternal/integrations/AGENTS.md— playbook for adding a new third-party integration (Jira/Linear pattern)cmd/mock-agent/AGENTS.md— predefined/e2e:<name>scenarios vs inlinee2e:...scripts, recipe for adding a scenario, and the rebuild-before-e2e requirement