Merge branch 'main' into 06-26-feat_reduce_prerequisites_markdown_font_size

This commit is contained in:
Andrew Aquino
2026-06-26 19:39:31 +00:00
248 changed files with 7343 additions and 1182 deletions
+3 -6
View File
@@ -8,12 +8,9 @@ This guide documents structure, research, and content patterns for documentation
> Read that first. When this style guide conflicts with the content
> guidelines, the content guidelines govern.
>
> **For prose rules**, the canonical Coder documentation style guide lives
> at [`docs/.style/style-guide.md`](../../docs/.style/style-guide.md) and
> will be enforced by the Vale rules under `docs/.style/styles/Coder/`.
> That guide is currently a scaffold; continue using the **Writing Style**
> section below until it is populated. This file also remains authoritative
> for structure, research, and content patterns.
> **For prose rules**, refer to the canonical Coder documentation style guide at [`docs/.style/style-guide/`](../../docs/.style/style-guide/README.md).
> Vale rules under `docs/.style/styles/Coder/` enforce those rules incrementally as each rule lands.
> This file remains authoritative for structure, research, and content patterns.
See [CONTRIBUTING.md](../../docs/about/contributing/CONTRIBUTING.md) for general contribution guidelines.
+5 -2
View File
@@ -183,7 +183,7 @@ jobs:
# `**.md` (not `docs/**.md`) because the action's globber collapses a
# `**` adjacent to `.md` to a single path segment, so `docs/**.md`
# only matches top-level docs/*.md and misses nested pages such as
# docs/.style/style-guide.md. The prose step below re-filters to
# docs/.style/style-guide/README.md. The prose step below re-filters to
# docs/ paths.
# Cache split into restore + conditional save to avoid letting PR
# runs populate a cache that other branches restore from (the
@@ -318,7 +318,10 @@ jobs:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 1
# Depth 2 makes the PR merge commit's first parent (the base
# branch tip) available so lint/emdash can diff against HEAD^
# without fetching the base branch at runtime.
fetch-depth: 2
persist-credentials: false
- name: Set up mise tools
+5
View File
@@ -77,6 +77,11 @@ jobs:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
# Depth 2 makes the PR merge commit's first parent (the base
# branch tip) available so lint/emdash, run inside the image via
# scripts/dogfood_test_image.sh, can diff against HEAD^1 without
# fetching the base branch at runtime.
fetch-depth: 2
persist-credentials: false
- name: Get branch name
@@ -0,0 +1,80 @@
name: Publish to MCP Registry
on:
release:
types: [published]
workflow_dispatch:
inputs:
version:
description: "Version to publish (semver, e.g. 2.20.0). Used only for manual runs."
required: false
type: string
publish:
description: "Actually publish to the live registry. Leave false to validate only."
required: false
default: false
type: boolean
jobs:
publish-mcp:
runs-on: ubuntu-latest
permissions:
id-token: write # Required for GitHub OIDC
contents: read
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
persist-credentials: false
- name: Install mcp-publisher
run: |
curl -L "https://github.com/modelcontextprotocol/registry/releases/latest/download/mcp-publisher_$(uname -s | tr '[:upper:]' '[:lower:]')_$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/').tar.gz" | tar xz mcp-publisher
- name: Determine version
id: version
env:
EVENT_NAME: ${{ github.event_name }}
INPUT_VERSION: ${{ inputs.version }}
run: |
set -euo pipefail
if [ "${EVENT_NAME}" = "release" ]; then
# Tag refs look like refs/tags/v2.20.0
VERSION="${GITHUB_REF#refs/tags/v}"
else
VERSION="${INPUT_VERSION}"
fi
if [ -z "${VERSION}" ]; then
echo "::error::No version provided. Pass the 'version' input for manual runs."
exit 1
fi
# Reject anything that isn't clean semver so we never publish refs/heads/... etc.
if ! echo "${VERSION}" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$'; then
echo "::error::Refusing to publish invalid version '${VERSION}'."
exit 1
fi
echo "version=${VERSION}" >> "${GITHUB_OUTPUT}"
- name: Set version in server.json
env:
VERSION: ${{ steps.version.outputs.version }}
run: |
set -euo pipefail
jq --arg v "${VERSION}" '.version = $v' server.json > server.tmp
mv server.tmp server.json
cat server.json
- name: Validate server.json (no publish)
run: ./mcp-publisher validate
- name: Authenticate to MCP Registry
if: github.event_name == 'release' || inputs.publish
run: ./mcp-publisher login github-oidc
- name: Publish server to MCP Registry
if: github.event_name == 'release' || inputs.publish
run: ./mcp-publisher publish
+2 -1
View File
@@ -12,7 +12,8 @@ Rule #1: If you want exception to ANY rule, YOU MUST STOP and get explicit permi
- Docs content scope: Use [Coder Docs Content Guidelines](docs/.style/content-guidelines.md) to decide whether a piece of content belongs in `docs/` at all. The Documentation Style Guide above covers prose and formatting; the content guidelines govern scope and routing and supersede the style guide on conflicts.
- Compatibility: `.agents/docs` symlinks to `.claude/docs` for agent runtimes that look there.
- Frontend: Read [Frontend Development Guidelines](site/AGENTS.md) before changing anything under `site/`.
- Docs prose: When editing anything under `docs/`, see the prose style guide at [`docs/.style/style-guide.md`](docs/.style/style-guide.md). It is currently a scaffold; until it is populated, use the **Writing Style** section in [`.claude/docs/DOCS_STYLE_GUIDE.md`](.claude/docs/DOCS_STYLE_GUIDE.md). That file also covers structure, research, and content patterns.
- Docs prose: When editing anything under `docs/`, refer to the prose style guide at [`docs/.style/style-guide/`](docs/.style/style-guide/README.md).
For supporting agent-specific guidance, refer to [`.claude/docs/DOCS_STYLE_GUIDE.md`](.claude/docs/DOCS_STYLE_GUIDE.md), which covers structure, research, and content patterns.
## Foundational rules
+2 -4
View File
@@ -964,13 +964,11 @@ func TestProcessOutput(t *testing.T) {
codes [2]int
)
for i := range 2 {
wg.Add(1)
go func() {
defer wg.Done()
wg.Go(func() {
w := getOutputWithWait(t, handler, id)
codes[i] = w.Code
_ = json.NewDecoder(w.Body).Decode(&resps[i])
}()
})
}
// Signal the process to exit so both waiters unblock.
+2 -4
View File
@@ -300,13 +300,11 @@ func BenchmarkSearch_ConcurrentReads_Throughput(b *testing.B) {
perGoroutine = 1
}
for gi := 0; gi < g; gi++ {
wg.Add(1)
go func() {
defer wg.Done()
wg.Go(func() {
for j := 0; j < perGoroutine; j++ {
_ = filefinder.SearchSnapshotForTest(plan, snap, maxCands)
}
}()
})
}
wg.Wait()
totalOps := float64(g * perGoroutine)
@@ -756,13 +756,11 @@ func TestBackedPipe_DuplicateReconnectionPrevention(t *testing.T) {
// Start all goroutines
for i := 0; i < numConcurrent; i++ {
wg.Add(1)
go func(idx int) {
defer wg.Done()
wg.Go(func() {
// Wait for the signal to start
<-startSignals[idx]
errors[idx] = bp.ForceReconnect()
}(i)
<-startSignals[i]
errors[i] = bp.ForceReconnect()
})
}
// Start the first ForceReconnect and wait for it to block
@@ -883,14 +883,12 @@ func TestBackedWriter_MultipleWritesDuringReconnect(t *testing.T) {
writesStarted := make(chan struct{}, numWriters)
for i := 0; i < numWriters; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
wg.Go(func() {
// Signal that this write is starting
writesStarted <- struct{}{}
data := []byte{byte('A' + id)}
_, writeResults[id] = bw.Write(data)
}(i)
data := []byte{byte('A' + i)}
_, writeResults[i] = bw.Write(data)
})
}
// Wait for all writes to start
+26 -38
View File
@@ -244,16 +244,14 @@ func TestGraphThreadSafety(t *testing.T) {
barrier := make(chan struct{})
// Launch writers
for i := 0; i < numWriters; i++ {
wg.Add(1)
go func(writerID int) {
defer wg.Done()
wg.Go(func() {
<-barrier
for j := 0; j < operationsPerWriter; j++ {
from := &testGraphVertex{Name: fmt.Sprintf("writer-%d-%d", writerID, j)}
to := &testGraphVertex{Name: fmt.Sprintf("writer-%d-%d", writerID, j+1)}
from := &testGraphVertex{Name: fmt.Sprintf("writer-%d-%d", i, j)}
to := &testGraphVertex{Name: fmt.Sprintf("writer-%d-%d", i, j+1)}
graph.AddEdge(from, to, testEdgeCompleted)
}
}(i)
})
}
// Launch readers
@@ -263,20 +261,18 @@ func TestGraphThreadSafety(t *testing.T) {
}, numReaders)
for i := 0; i < numReaders; i++ {
wg.Add(1)
go func(readerID int) {
defer wg.Done()
wg.Go(func() {
<-barrier
defer func() {
if r := recover(); r != nil {
readerResults[readerID].panicked = true
readerResults[i].panicked = true
}
}()
readCount := 0
for j := 0; j < operationsPerReader; j++ {
// Create a test vertex and read
testUnit := &testGraphVertex{Name: fmt.Sprintf("test-reader-%d-%d", readerID, j)}
testUnit := &testGraphVertex{Name: fmt.Sprintf("test-reader-%d-%d", i, j)}
forwardEdges := graph.GetForwardAdjacentVertices(testUnit)
reverseEdges := graph.GetReverseAdjacentVertices(testUnit)
@@ -285,8 +281,8 @@ func TestGraphThreadSafety(t *testing.T) {
_ = reverseEdges
readCount++
}
readerResults[readerID].readCount = readCount
}(i)
readerResults[i].readCount = readCount
})
}
close(barrier)
@@ -324,13 +320,11 @@ func TestGraphThreadSafety(t *testing.T) {
// Launch goroutines trying to add D→A (creates cycle)
for i := 0; i < numGoroutines; i++ {
wg.Add(1)
go func(goroutineID int) {
defer wg.Done()
wg.Go(func() {
<-barrier
err := graph.AddEdge(unitD, unitA, testEdgeCompleted)
cycleErrors[goroutineID] = err
}(i)
cycleErrors[i] = err
})
}
close(barrier)
@@ -370,28 +364,24 @@ func TestGraphThreadSafety(t *testing.T) {
// Launch readers calling ToDOT
dotErrors := make([]error, numReaders)
for i := 0; i < numReaders; i++ {
wg.Add(1)
go func(readerID int) {
defer wg.Done()
wg.Go(func() {
<-barrier
dot, err := graph.ToDOT(fmt.Sprintf("test-%d", readerID))
dotErrors[readerID] = err
dot, err := graph.ToDOT(fmt.Sprintf("test-%d", i))
dotErrors[i] = err
if err == nil {
dotResults[readerID] = dot
dotResults[i] = dot
}
}(i)
})
}
// Launch writers adding edges
for i := 0; i < numWriters; i++ {
wg.Add(1)
go func(writerID int) {
defer wg.Done()
wg.Go(func() {
<-barrier
from := &testGraphVertex{Name: fmt.Sprintf("writer-dot-%d", writerID)}
to := &testGraphVertex{Name: fmt.Sprintf("writer-dot-target-%d", writerID)}
from := &testGraphVertex{Name: fmt.Sprintf("writer-dot-%d", i)}
to := &testGraphVertex{Name: fmt.Sprintf("writer-dot-target-%d", i)}
graph.AddEdge(from, to, testEdgeCompleted)
}(i)
})
}
close(barrier)
@@ -418,9 +408,7 @@ func BenchmarkGraph_ConcurrentMixedOperations(b *testing.B) {
for i := 0; i < b.N; i++ {
// Launch goroutines performing random operations
for j := 0; j < numGoroutines; j++ {
wg.Add(1)
go func(goroutineID int) {
defer wg.Done()
wg.Go(func() {
operationCount := 0
for operationCount < 50 {
@@ -428,7 +416,7 @@ func BenchmarkGraph_ConcurrentMixedOperations(b *testing.B) {
if operation < 0.6 { // 60% reads
// Read operation
testUnit := &testGraphVertex{Name: fmt.Sprintf("bench-read-%d-%d", goroutineID, operationCount)}
testUnit := &testGraphVertex{Name: fmt.Sprintf("bench-read-%d-%d", j, operationCount)}
forwardEdges := graph.GetForwardAdjacentVertices(testUnit)
reverseEdges := graph.GetReverseAdjacentVertices(testUnit)
@@ -437,14 +425,14 @@ func BenchmarkGraph_ConcurrentMixedOperations(b *testing.B) {
_ = reverseEdges
} else { // 40% writes
// Write operation
from := &testGraphVertex{Name: fmt.Sprintf("bench-write-%d-%d", goroutineID, operationCount)}
to := &testGraphVertex{Name: fmt.Sprintf("bench-write-target-%d-%d", goroutineID, operationCount)}
from := &testGraphVertex{Name: fmt.Sprintf("bench-write-%d-%d", j, operationCount)}
to := &testGraphVertex{Name: fmt.Sprintf("bench-write-target-%d-%d", j, operationCount)}
graph.AddEdge(from, to, testEdgeCompleted)
}
operationCount++
}
}(j)
})
}
wg.Wait()
+1 -12
View File
@@ -69,11 +69,6 @@ var bedrockSupportedBetaFlags = map[string]bool{
type BedrockRuntime struct {
Cfg aibconfig.AWSBedrock
Creds aws.CredentialsProvider
// ResolvedRegion is the region the AWS SDK resolved at construction (from
// the environment, shared config, or IMDS). It is used for request signing
// when Cfg.Region is empty, e.g. a custom base URL with the region supplied
// via AWS_REGION.
ResolvedRegion string
}
type interceptionBase struct {
@@ -298,14 +293,8 @@ func (i *interceptionBase) withAWSBedrockOptions(ctx context.Context) ([]option.
return nil, xerrors.Errorf("resolve AWS credentials: %w", err)
}
// Fall back to the SDK-resolved region (e.g. from AWS_REGION) when no
// explicit region is configured.
region := cfg.Region
if region == "" {
region = i.bedrock.ResolvedRegion
}
awsCfg := aws.Config{
Region: region,
Region: cfg.Region,
Credentials: i.bedrock.Creds,
}
@@ -474,12 +474,10 @@ func TestCircuitBreaker_HalfOpenMaxRequests(t *testing.T) {
responses := make(chan int, totalRequests)
for i := 0; i < totalRequests; i++ {
wg.Add(1)
go func() {
defer wg.Done()
wg.Go(func() {
status := doRequest()
responses <- status
}()
})
}
wg.Wait()
+2 -4
View File
@@ -619,11 +619,9 @@ func TestKeyConcurrent(t *testing.T) {
const numGoroutines = 10
var wg sync.WaitGroup
for r := range numGoroutines {
wg.Add(1)
go func(r int) {
defer wg.Done()
wg.Go(func() {
tc.run(r, key)
}(r)
})
}
wg.Wait()
+8 -2
View File
@@ -63,11 +63,17 @@ func NewAnthropic(ctx context.Context, cfg config.Anthropic, bedrockCfg *config.
// so it is cheap to run at construction.
var bedrock *messages.BedrockRuntime
if bedrockCfg != nil {
creds, region, err := buildBedrockCredentials(ctx, *bedrockCfg)
creds, resolvedRegion, err := buildBedrockCredentials(ctx, *bedrockCfg)
if err != nil {
return nil, xerrors.Errorf("build bedrock credentials: %w", err)
}
bedrock = &messages.BedrockRuntime{Cfg: *bedrockCfg, Creds: creds, ResolvedRegion: region}
runtimeCfg := *bedrockCfg
// resolvedRegion is bedrockCfg.Region if provided;
// otherwise, it is resolved from the environment via awsconfig.LoadDefaultConfig
if runtimeCfg.Region == "" {
runtimeCfg.Region = resolvedRegion
}
bedrock = &messages.BedrockRuntime{Cfg: runtimeCfg, Creds: creds}
}
return &Anthropic{
+4 -4
View File
@@ -42,8 +42,8 @@ func (r *RootCmd) scaletestBridge() *serpent.Command {
cmd := &serpent.Command{
Use: "bridge",
Short: "Generate load on the AI Bridge service.",
Long: `Generate load for AI Bridge testing. Supports two modes: 'bridge' mode routes requests through the Coder AI Bridge, 'direct' mode makes requests directly to an upstream URL (useful for baseline comparisons).
Short: "Generate load on the AI Gateway service.",
Long: `Generate load for AI Gateway testing. Supports two modes: 'bridge' mode routes requests through the Coder AI Gateway, 'direct' mode makes requests directly to an upstream URL (useful for baseline comparisons).
Examples:
# Test OpenAI API through bridge
@@ -100,7 +100,7 @@ Examples:
userConfig = createusers.Config{
OrganizationID: me.OrganizationIDs[0],
}
_, _ = fmt.Fprintln(inv.Stderr, "Bridge mode: creating users and making requests through AI Bridge...")
_, _ = fmt.Fprintln(inv.Stderr, "Bridge mode: creating users and making requests through AI Gateway...")
} else {
_, _ = fmt.Fprintf(inv.Stderr, "Direct mode: making requests directly to %s\n", upstreamURL)
}
@@ -210,7 +210,7 @@ Examples:
Flag: "mode",
Env: "CODER_SCALETEST_BRIDGE_MODE",
Default: "direct",
Description: "Request mode: 'bridge' (create users and use AI Bridge) or 'direct' (make requests directly to upstream-url).",
Description: "Request mode: 'bridge' (create users and use AI Gateway) or 'direct' (make requests directly to upstream-url).",
Value: serpent.EnumOf(&mode, string(bridge.RequestModeBridge), string(bridge.RequestModeDirect)),
},
{
+2 -4
View File
@@ -429,11 +429,9 @@ func setupTestListener(t *testing.T, l net.Listener, prefix []byte) string {
return
}
wg.Add(1)
go func() {
wg.Go(func() {
echoIfPrefixed(t, c, prefix)
wg.Done()
}()
})
}
}()
+1
View File
@@ -280,6 +280,7 @@ func createOIDCConfig(ctx context.Context, logger slog.Logger, vals *codersdk.De
IconURL: vals.OIDC.IconURL.String(),
IgnoreEmailVerified: vals.OIDC.IgnoreEmailVerified.Value(),
PKCEMethods: pkceSupport.CodeChallengeMethodsSupported,
EmailFallback: vals.OIDC.EmailFallback.Value(),
}, nil
}
+6 -12
View File
@@ -1360,12 +1360,10 @@ func TestSSH(t *testing.T) {
return
}
wg.Add(1)
go func() {
defer wg.Done()
wg.Go(func() {
defer fd.Close()
agentssh.Bicopy(ctx, fd, fd)
}()
})
}
})
@@ -1426,12 +1424,10 @@ func TestSSH(t *testing.T) {
return
}
wg.Add(1)
go func() {
defer wg.Done()
wg.Go(func() {
defer fd.Close()
agentssh.Bicopy(ctx, fd, fd)
}()
})
}
})
@@ -1576,12 +1572,10 @@ func TestSSH(t *testing.T) {
return
}
wg.Add(1)
go func() {
defer wg.Done()
wg.Go(func() {
defer fd.Close()
agentssh.Bicopy(ctx, fd, fd)
}()
})
}
})
+3
View File
@@ -964,6 +964,9 @@ These options are only available in the Enterprise Edition.
--browser-only bool, $CODER_BROWSER_ONLY
Whether Coder only allows connections to workspaces via the browser.
--cluster-host string, $CODER_CLUSTER_HOST
Hostname or (more commonly) IP to reach this replica for clustering.
--derp-server-relay-url url, $CODER_DERP_SERVER_RELAY_URL
An HTTP URL that is accessible by other replicas to relay DERP
traffic. Required for high availability.
+14
View File
@@ -190,6 +190,13 @@ networking:
# Whether Coder only allows connections to workspaces via the browser.
# (default: <unset>, type: bool)
browserOnly: false
# Configure network clustering. Coder Servers in the primary region form a cluster
# by
# communicating directly.
cluster:
# Hostname or (more commonly) IP to reach this replica for clustering.
# (default: <unset>, type: string)
clusterHost: ""
# Interval to poll for scheduled workspace builds.
# (default: 1m0s, type: duration)
autobuildPollInterval: 1m0s
@@ -434,6 +441,13 @@ oidc:
# next login.
# (default: true, type: bool)
oidc-repair-links: true
# INSECURE: Allow OIDC logins to fall back to email-based matching when the
# linked_id (issuer+subject) does not match an existing user link. Required for
# IdP brokers that do not issue a stable 'sub' for the same user across
# connections. The existing user_link's linked_id is preserved on fallback. Only
# enable if you understand and accept the risk.
# (default: <unset>, type: bool)
dangerousOidcEmailFallback: false
# Telemetry is critical to our ability to improve Coder. We strip all personal
# information before sending data to our servers. Please only disable telemetry
# when required by your organization's security policy.
+2 -12
View File
@@ -6,7 +6,6 @@ import (
"io"
"net/http"
"golang.org/x/xerrors"
"storj.io/drpc/drpcmux"
"storj.io/drpc/drpcserver"
@@ -71,17 +70,8 @@ func (api *API) CreateInMemoryAIBridgeServer(dialCtx context.Context) (client ai
if err != nil {
return nil, err
}
err = aibridgedproto.DRPCRegisterRecorder(mux, srv)
if err != nil {
return nil, xerrors.Errorf("register recorder service: %w", err)
}
err = aibridgedproto.DRPCRegisterMCPConfigurator(mux, srv)
if err != nil {
return nil, xerrors.Errorf("register MCP configurator service: %w", err)
}
err = aibridgedproto.DRPCRegisterAuthorizer(mux, srv)
if err != nil {
return nil, xerrors.Errorf("register key validator service: %w", err)
if err := aibridgedserver.Register(mux, srv); err != nil {
return nil, err
}
server := drpcserver.NewWithOptions(&tracing.DRPCHandler{Handler: mux},
drpcserver.Options{
+19
View File
@@ -0,0 +1,19 @@
package proto
import "github.com/coder/coder/v2/apiversion"
// Version history:
//
// API v1.0:
// - Initial version. Serves the Recorder, MCPConfigurator, and Authorizer
// services to embedded and standalone AI Gateway daemons.
const (
CurrentMajor = 1
CurrentMinor = 0
)
// CurrentVersion is the current aibridged API version.
// Breaking changes to the aibridged API **MUST** increment CurrentMajor above.
// Non-breaking changes to the aibridged API **MUST** increment CurrentMinor
// above.
var CurrentVersion = apiversion.New(CurrentMajor, CurrentMinor)
+7 -7
View File
@@ -594,13 +594,13 @@ externalAuthLoop:
// IsAuthorized validates a given Coder API key and returns the user ID to which it belongs (if valid).
//
// SECURITY: when in.KeyId is set (the "delegated" path), this method trusts the
// caller's claim of identity and skips the key-secret check. This is safe only
// because the DRPCServer is reachable solely via the in-process
// [aibridged.MemTransportPipe]; the handler itself cannot tell whether it was
// invoked over the in-memory pipe or a network socket. If this RPC is ever
// exposed over a network boundary, any caller who knows a valid 10-char key ID
// (which is not secret) could authenticate as the key's owner without the
// secret. Do not bind this DRPCServer to a network listener.
// caller's claim of identity and skips the key-secret check. This DRPCServer is
// reachable both in-process via [aibridged.MemTransportPipe] and over the network
// via the /api/v2/ai-gateway/serve endpoint. That endpoint admits only holders of
// AI Gateway key, which are fully trusted. Standalone AI Gateway authenticates its
// own users and acts on their behalf, much like a provisioner daemon. A Gateway key
// holder can therefore act as any user without that user's secret. Per-user
// authorization on this surface is a known gap.
//
// NOTE: this should really be using the code from [httpmw.ExtractAPIKey]. That function not only validates the key
// but handles many other cases like updating last used, expiry, etc. This code does not currently use it for
+25
View File
@@ -0,0 +1,25 @@
package aibridgedserver
import (
"golang.org/x/xerrors"
"storj.io/drpc/drpcmux"
"github.com/coder/coder/v2/coderd/aibridged/proto"
)
// Register registers the Recorder, MCPConfigurator, and Authorizer DRPC
// services backed by srv onto mux. It is shared by the embedded in-memory
// server and the standalone /api/v2/ai-gateway/serve WebSocket handler so both
// expose an identical service set.
func Register(mux *drpcmux.Mux, srv *Server) error {
if err := proto.DRPCRegisterRecorder(mux, srv); err != nil {
return xerrors.Errorf("register recorder service: %w", err)
}
if err := proto.DRPCRegisterMCPConfigurator(mux, srv); err != nil {
return xerrors.Errorf("register MCP configurator service: %w", err)
}
if err := proto.DRPCRegisterAuthorizer(mux, srv); err != nil {
return xerrors.Errorf("register authorizer service: %w", err)
}
return nil
}
+94
View File
@@ -0,0 +1,94 @@
//go:build !slim
// Package aibridgedtest provides helpers for starting an in-process
// aibridged daemon in tests.
package aibridgedtest
import (
"context"
"testing"
"github.com/prometheus/client_golang/prometheus"
"go.opentelemetry.io/otel"
"cdr.dev/slog/v3"
"github.com/coder/coder/v2/cli"
"github.com/coder/coder/v2/coderd"
"github.com/coder/coder/v2/coderd/aibridged"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/codersdk"
)
// StartTestAIBridgeDaemon wires an in-process aibridged daemon onto the
// supplied API, mirroring what cli/server.go does in production. Tests that
// create AI provider rows with BaseURL pointing at fake upstream HTTP servers
// (e.g. chattest.NewOpenAI) will have their requests proxied through the real
// aibridged stack as they would in production.
//
// metrics is the registry the daemon reports provider reload events to.
// The caller owns the metrics instance and can assert on it after the daemon
// runs. Use [aibridged.NewMetrics] to create one, or nil for a throwaway.
func StartTestAIBridgeDaemon(
ctx context.Context,
t testing.TB,
api *coderd.API,
metrics *aibridged.Metrics,
) {
t.Helper()
logger := api.Logger.Named("aibridged").Leveled(slog.LevelDebug)
cfg := api.DeploymentValues.AI.BridgeConfig
tracer := otel.Tracer("aibridge-test")
providers, _, err := cli.BuildProviders(ctx, api.Database, cfg, logger, nil)
if err != nil {
t.Fatalf("build providers: %v", err)
}
pool, err := aibridged.NewCachedBridgePool(aibridged.DefaultPoolOptions, providers, logger.Named("pool"), nil, tracer)
if err != nil {
t.Fatalf("create bridge pool: %v", err)
}
t.Cleanup(func() { _ = pool.Shutdown(context.Background()) })
if metrics == nil {
metrics = aibridged.NewMetrics(prometheus.NewRegistry())
}
reloader := &testPoolReloader{pool: pool, db: api.Database, cfg: cfg, logger: logger.Named("reloader"), metrics: metrics}
unsubscribe, err := aibridged.SubscribeProviderReload(ctx, api.Pubsub, reloader, logger.Named("subscriber"))
if err != nil {
t.Fatalf("subscribe provider reload: %v", err)
}
t.Cleanup(unsubscribe)
srv, err := aibridged.New(ctx, pool, func(dialCtx context.Context) (aibridged.DRPCClient, error) {
return api.CreateInMemoryAIBridgeServer(dialCtx)
}, logger, tracer)
if err != nil {
t.Fatalf("create aibridged server: %v", err)
}
t.Cleanup(func() { _ = srv.Close() })
api.RegisterInMemoryAIBridgedHTTPHandler(srv)
}
type testPoolReloader struct {
pool *aibridged.CachedBridgePool
db database.Store
cfg codersdk.AIBridgeConfig
logger slog.Logger
metrics *aibridged.Metrics
}
func (r *testPoolReloader) Reload(ctx context.Context) error {
// Stamp the attempt before building providers so the gap between
// attempt and success timestamps reveals a mid-reload hang.
r.metrics.RecordReloadAttempt()
providers, outcomes, err := cli.BuildProviders(ctx, r.db, r.cfg, r.logger, nil)
if err != nil {
return err
}
r.pool.ReplaceProviders(providers)
r.metrics.RecordReloadSuccess(outcomes)
return nil
}
+42 -1
View File
@@ -1532,6 +1532,25 @@ const docTemplate = `{
]
}
},
"/api/v2/ai-gateway/serve": {
"get": {
"tags": [
"Enterprise"
],
"summary": "AI Gateway serve",
"operationId": "ai-gateway-serve",
"responses": {
"101": {
"description": "Switching Protocols"
}
},
"security": [
{
"AIGatewayKey": []
}
]
}
},
"/api/v2/ai-gateway/sessions": {
"get": {
"description": "Alias: also available at /api/v2/aibridge/sessions for backward compatibility.",
@@ -15256,7 +15275,7 @@ const docTemplate = `{
"key_prefix": {
"type": "string"
},
"last_used_at": {
"last_heartbeat_at": {
"type": "string",
"format": "date-time"
},
@@ -15490,6 +15509,7 @@ const docTemplate = `{
"ai_gateway_key:create",
"ai_gateway_key:delete",
"ai_gateway_key:read",
"ai_gateway_key:update",
"ai_model_price:*",
"ai_model_price:read",
"ai_model_price:update",
@@ -15724,6 +15744,7 @@ const docTemplate = `{
"APIKeyScopeAiGatewayKeyCreate",
"APIKeyScopeAiGatewayKeyDelete",
"APIKeyScopeAiGatewayKeyRead",
"APIKeyScopeAiGatewayKeyUpdate",
"APIKeyScopeAiModelPriceAll",
"APIKeyScopeAiModelPriceRead",
"APIKeyScopeAiModelPriceUpdate",
@@ -17838,6 +17859,14 @@ const docTemplate = `{
"ChatWatchEventKindContextDirty"
]
},
"codersdk.ClusterConfig": {
"type": "object",
"properties": {
"host": {
"type": "string"
}
}
},
"codersdk.ConnectionLatency": {
"type": "object",
"properties": {
@@ -19174,6 +19203,9 @@ const docTemplate = `{
"cli_upgrade_message": {
"type": "string"
},
"cluster": {
"$ref": "#/definitions/codersdk.ClusterConfig"
},
"config": {
"type": "string"
},
@@ -21272,6 +21304,10 @@ const docTemplate = `{
"type": "string"
}
},
"email_fallback": {
"description": "EmailFallback allows OIDC logins to fall back to email-based matching\nwhen the ` + "`" + `linked_id` + "`" + ` (issuer+subject) does not match an existing user\nlink. INSECURE: weakens the linked_id check. It exists for IdP\nbrokers that do not issue a stable ` + "`" + `sub` + "`" + ` for the same user across\nconnections.",
"type": "boolean"
},
"email_field": {
"type": "string"
},
@@ -28895,6 +28931,11 @@ const docTemplate = `{
}
},
"securityDefinitions": {
"AIGatewayKey": {
"type": "apiKey",
"name": "X-AI-Governance-Gateway-Key",
"in": "header"
},
"Authorization": {
"type": "apiKey",
"name": "Authorizaiton",
+40 -1
View File
@@ -1355,6 +1355,23 @@
]
}
},
"/api/v2/ai-gateway/serve": {
"get": {
"tags": ["Enterprise"],
"summary": "AI Gateway serve",
"operationId": "ai-gateway-serve",
"responses": {
"101": {
"description": "Switching Protocols"
}
},
"security": [
{
"AIGatewayKey": []
}
]
}
},
"/api/v2/ai-gateway/sessions": {
"get": {
"description": "Alias: also available at /api/v2/aibridge/sessions for backward compatibility.",
@@ -13604,7 +13621,7 @@
"key_prefix": {
"type": "string"
},
"last_used_at": {
"last_heartbeat_at": {
"type": "string",
"format": "date-time"
},
@@ -13830,6 +13847,7 @@
"ai_gateway_key:create",
"ai_gateway_key:delete",
"ai_gateway_key:read",
"ai_gateway_key:update",
"ai_model_price:*",
"ai_model_price:read",
"ai_model_price:update",
@@ -14064,6 +14082,7 @@
"APIKeyScopeAiGatewayKeyCreate",
"APIKeyScopeAiGatewayKeyDelete",
"APIKeyScopeAiGatewayKeyRead",
"APIKeyScopeAiGatewayKeyUpdate",
"APIKeyScopeAiModelPriceAll",
"APIKeyScopeAiModelPriceRead",
"APIKeyScopeAiModelPriceUpdate",
@@ -16091,6 +16110,14 @@
"ChatWatchEventKindContextDirty"
]
},
"codersdk.ClusterConfig": {
"type": "object",
"properties": {
"host": {
"type": "string"
}
}
},
"codersdk.ConnectionLatency": {
"type": "object",
"properties": {
@@ -17376,6 +17403,9 @@
"cli_upgrade_message": {
"type": "string"
},
"cluster": {
"$ref": "#/definitions/codersdk.ClusterConfig"
},
"config": {
"type": "string"
},
@@ -19402,6 +19432,10 @@
"type": "string"
}
},
"email_fallback": {
"description": "EmailFallback allows OIDC logins to fall back to email-based matching\nwhen the `linked_id` (issuer+subject) does not match an existing user\nlink. INSECURE: weakens the linked_id check. It exists for IdP\nbrokers that do not issue a stable `sub` for the same user across\nconnections.",
"type": "boolean"
},
"email_field": {
"type": "string"
},
@@ -26664,6 +26698,11 @@
}
},
"securityDefinitions": {
"AIGatewayKey": {
"type": "apiKey",
"name": "X-AI-Governance-Gateway-Key",
"in": "header"
},
"Authorization": {
"type": "apiKey",
"name": "Authorizaiton",
+91 -13
View File
@@ -189,7 +189,7 @@ func (e *Executor) runOnce(t time.Time) Stats {
// NOTE: If a workspace build is created with a given TTL and then the user either
// changes or unsets the TTL, the deadline for the workspace build will not
// have changed. This behavior is as expected per #2229.
workspaces, err := e.db.GetWorkspacesEligibleForTransition(e.ctx, currentTick)
workspaces, err := e.db.GetWorkspacesEligibleForLifecycleAction(e.ctx, currentTick)
if err != nil {
e.log.Error(e.ctx, "get workspaces for autostart or autostop", slog.Error(err))
return stats
@@ -207,7 +207,7 @@ func (e *Executor) runOnce(t time.Time) Stats {
// set of identical template versions. Then unload the files when the builds
// are done. Right now, this relies on luck for the 10 goroutine workers to
// overlap and keep the file reference in the cache alive.
slices.SortFunc(workspaces, func(a, b database.GetWorkspacesEligibleForTransitionRow) int {
slices.SortFunc(workspaces, func(a, b database.GetWorkspacesEligibleForLifecycleActionRow) int {
return strings.Compare(a.BuildTemplateVersionID.UUID.String(), b.BuildTemplateVersionID.UUID.String())
})
@@ -232,6 +232,9 @@ func (e *Executor) runOnce(t time.Time) Stats {
auditLog *auditParams
shouldNotifyDormancy bool
shouldNotifyTaskPause bool
shouldRemind bool
reminderDeadline time.Time
reminderBuildID uuid.UUID
nextBuild *database.WorkspaceBuild
activeTemplateVersion database.TemplateVersion
ws database.Workspace
@@ -309,11 +312,29 @@ func (e *Executor) runOnce(t time.Time) Stats {
nextTransition, reason, err := getNextTransition(user, ws, latestBuild, latestJob, templateSchedule, currentTick)
if err != nil {
log.Debug(e.ctx, "skipping workspace", slog.Error(err))
// err is used to indicate that a workspace is not eligible
// so returning nil here is ok although ultimately the distinction
// doesn't matter since the transaction is read-only up to
// this point.
return xerrors.Errorf("get next transition: %w", err)
}
// No transition is due. The workspace may still need a one-time
// autostop reminder; reuse the lock and transaction we already
// hold to stamp the marker.
if reason == "" {
log.Debug(e.ctx, "skipping workspace, no transition due")
// A deadline change (e.g. activity bump) re-arms the reminder; users near
// the boundary may receive one reminder per bump. Intentional: one-per-build
// would leave stale reminders after a bump.
if shouldRemindAutostop(latestBuild, templateSchedule, currentTick) {
if err := tx.UpdateWorkspaceBuildNotifiedAutostopDeadline(e.ctx, database.UpdateWorkspaceBuildNotifiedAutostopDeadlineParams{
ID: latestBuild.ID,
NotifiedAutostopDeadline: latestBuild.Deadline,
UpdatedAt: dbtime.Now(),
}); err != nil {
return xerrors.Errorf("stamp autostop reminder marker: %w", err)
}
reminderDeadline = latestBuild.Deadline
reminderBuildID = latestBuild.ID
shouldRemind = true
}
return nil
}
@@ -536,6 +557,24 @@ func (e *Executor) runOnce(t time.Time) Stats {
}
}
}
if shouldRemind {
// At-most-once: the marker is already committed, so a failed
// enqueue only logs (no retry).
if _, err := e.notificationsEnqueuer.Enqueue(
e.ctx,
ws.OwnerID,
notifications.TemplateWorkspaceAutostopReminder,
map[string]string{
"workspace": ws.Name,
"deadline": reminderDeadline.UTC().Format(time.RFC1123),
},
"lifecycle_executor",
// Associate this notification with all the related entities.
ws.ID, ws.OwnerID, ws.TemplateID, ws.OrganizationID,
); err != nil {
log.Warn(e.ctx, "failed to notify of upcoming workspace autostop", slog.F("build_id", reminderBuildID), slog.Error(err))
}
}
return nil
}()
if err != nil && !xerrors.Is(err, context.Canceled) {
@@ -559,12 +598,50 @@ func (e *Executor) runOnce(t time.Time) Stats {
return stats
}
// shouldRemindAutostop reports whether a reminder notification should be sent
// for the workspace's latest build at currentTick.
//
// time_til_autostop_notify has no upper bound. If it exceeds a
// workspace's remaining lifetime, the notify window already covers "now" at
// build creation. This is still safe: we require deadline > now (so we never
// remind once the stop is due) and the marker (NotifiedAutostopDeadline ==
// Deadline, stamped in the transaction before the send attempt) filters every
// subsequent tick. The result is exactly one reminder per deadline, never one
// per tick.
func shouldRemindAutostop(build database.WorkspaceBuild, templateSchedule schedule.TemplateScheduleOptions, currentTick time.Time) bool {
if templateSchedule.TimeTilAutostopNotify <= 0 {
return false
}
if build.Transition != database.WorkspaceTransitionStart || build.Deadline.IsZero() {
return false
}
if !build.Deadline.After(currentTick) {
return false
}
// "now" must be within the lead window before the deadline, i.e.
// deadline <= now + time_til_autostop_notify.
if build.Deadline.After(currentTick.Add(templateSchedule.TimeTilAutostopNotify)) {
return false
}
// Idempotence: a reminder has not yet been sent for THIS deadline. The
// marker re-arms automatically when the deadline changes (e.g. an activity
// bump), so a new reminder fires once the new deadline re-enters the window.
return !build.NotifiedAutostopDeadline.Equal(build.Deadline)
}
// getNextTransition returns the next eligible transition for the workspace
// as well as the reason for why it is transitioning. It is possible
// for this function to return a nil error as well as an empty transition.
// In such cases it means no provisioning should occur but the workspace
// may be "transitioning" to a new state (such as an inactive, stopped
// workspace transitioning to the dormant state).
// as well as the reason for why it is transitioning. It is possible for this
// function to return a nil error as well as an empty transition with a
// non-empty reason. In such cases it means no provisioning should occur but
// the workspace may be "transitioning" to a new state (such as an inactive,
// stopped workspace transitioning to the dormant state).
//
// When nothing is due, it returns an empty transition, an empty reason, and a
// nil error. Callers gate on reason == "" for the "nothing to do" case.
func getNextTransition(
user database.User,
ws database.Workspace,
@@ -604,7 +681,8 @@ func getNextTransition(
case isEligibleForDelete(ws, templateSchedule, latestBuild, latestJob, currentTick):
return database.WorkspaceTransitionDelete, database.BuildReasonAutodelete, nil
default:
return "", "", xerrors.Errorf("last transition not valid for autostart or autostop")
// No autostart, autostop, dormancy, or deletion transition is due.
return "", "", nil
}
}
@@ -112,6 +112,151 @@ func Test_getNextTransition_TaskAutoPause(t *testing.T) {
}
}
func Test_getNextTransition_NoAction(t *testing.T) {
t.Parallel()
now := time.Now()
// A stopped workspace with no autostart schedule, no dormancy, and no
// deletion configured has no transition due. The default case must report
// "nothing to do" via an empty transition AND empty reason, with a nil
// error (not a sentinel error).
user := database.User{Status: database.UserStatusActive}
ws := database.Workspace{
DormantAt: sql.NullTime{Valid: false},
}
build := database.WorkspaceBuild{
Transition: database.WorkspaceTransitionStop,
}
job := database.ProvisionerJob{
JobStatus: database.ProvisionerJobStatusSucceeded,
}
templateSchedule := schedule.TemplateScheduleOptions{}
transition, reason, err := getNextTransition(user, ws, build, job, templateSchedule, now)
require.NoError(t, err)
require.Equal(t, database.WorkspaceTransition(""), transition)
require.Equal(t, database.BuildReason(""), reason)
}
func TestShouldRemindAutostop(t *testing.T) {
t.Parallel()
currentTick := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
const ttl = time.Hour
// inWindow places the deadline 30m out, inside the 1h lead window.
inWindow := func() database.WorkspaceBuild {
return database.WorkspaceBuild{
Transition: database.WorkspaceTransitionStart,
Deadline: currentTick.Add(30 * time.Minute),
}
}
testCases := []struct {
Name string
Build database.WorkspaceBuild
TemplateSchedule schedule.TemplateScheduleOptions
Expected bool
}{
{
Name: "InWindow",
Build: inWindow(),
TemplateSchedule: schedule.TemplateScheduleOptions{TimeTilAutostopNotify: ttl},
Expected: true,
},
{
Name: "TemplateDisabled",
Build: inWindow(),
TemplateSchedule: schedule.TemplateScheduleOptions{TimeTilAutostopNotify: 0},
Expected: false,
},
{
Name: "TransitionStop",
Build: func() database.WorkspaceBuild {
b := inWindow()
b.Transition = database.WorkspaceTransitionStop
return b
}(),
TemplateSchedule: schedule.TemplateScheduleOptions{TimeTilAutostopNotify: ttl},
Expected: false,
},
{
Name: "ZeroDeadline",
Build: func() database.WorkspaceBuild {
b := inWindow()
b.Deadline = time.Time{}
return b
}(),
TemplateSchedule: schedule.TemplateScheduleOptions{TimeTilAutostopNotify: ttl},
Expected: false,
},
{
Name: "DeadlineInPast",
Build: func() database.WorkspaceBuild {
b := inWindow()
b.Deadline = currentTick.Add(-time.Minute)
return b
}(),
TemplateSchedule: schedule.TemplateScheduleOptions{TimeTilAutostopNotify: ttl},
Expected: false,
},
{
Name: "BeforeWindow",
Build: func() database.WorkspaceBuild {
b := inWindow()
// Deadline two hours out, ttl is only one hour.
b.Deadline = currentTick.Add(2 * time.Hour)
return b
}(),
TemplateSchedule: schedule.TemplateScheduleOptions{TimeTilAutostopNotify: ttl},
Expected: false,
},
{
Name: "AlreadyNotified",
Build: func() database.WorkspaceBuild {
b := inWindow()
b.NotifiedAutostopDeadline = b.Deadline
return b
}(),
TemplateSchedule: schedule.TemplateScheduleOptions{TimeTilAutostopNotify: ttl},
Expected: false,
},
{
// Deadline == currentTick: the stop is already due, so
// !build.Deadline.After(currentTick) rejects it (not a reminder).
Name: "ExactDeadline",
Build: func() database.WorkspaceBuild {
b := inWindow()
b.Deadline = currentTick
return b
}(),
TemplateSchedule: schedule.TemplateScheduleOptions{TimeTilAutostopNotify: ttl},
Expected: false,
},
{
// Deadline exactly at the window opening edge (now + ttl) is
// eligible: the lead-window check uses After, so the edge passes.
Name: "WindowEdge",
Build: func() database.WorkspaceBuild {
b := inWindow()
b.Deadline = currentTick.Add(ttl)
return b
}(),
TemplateSchedule: schedule.TemplateScheduleOptions{TimeTilAutostopNotify: ttl},
Expected: true,
},
}
for _, tc := range testCases {
t.Run(tc.Name, func(t *testing.T) {
t.Parallel()
require.Equal(t, tc.Expected, shouldRemindAutostop(tc.Build, tc.TemplateSchedule, currentTick))
})
}
}
func Test_isEligibleForAutostart(t *testing.T) {
t.Parallel()
+273
View File
@@ -4,6 +4,7 @@ import (
"context"
"database/sql"
"errors"
"sync"
"sync/atomic"
"testing"
"time"
@@ -13,6 +14,7 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/goleak"
"golang.org/x/xerrors"
"cdr.dev/slog/v3"
"cdr.dev/slog/v3/sloggers/slogtest"
@@ -1890,6 +1892,277 @@ func setupTestDBPrebuiltWorkspace(
return workspace
}
// setupAutostopReminderWorkspace provisions a running workspace whose template
// has the given time_til_autostop_notify configured, using the caller-supplied
// notifications enqueuer. It returns the harness channels needed to drive ticks
// and observe notifications.
func setupAutostopReminderWorkspace(t *testing.T, timeTilAutostopNotify time.Duration, enq notifications.Enqueuer) (
client *codersdk.Client,
tickCh chan time.Time,
statsCh chan autobuild.Stats,
workspace codersdk.Workspace,
) {
t.Helper()
tickCh = make(chan time.Time)
statsCh = make(chan autobuild.Stats)
client = coderdtest.New(t, &coderdtest.Options{
AutobuildTicker: tickCh,
AutobuildStats: statsCh,
IncludeProvisionerDaemon: true,
NotificationsEnqueuer: enq,
// The AGPL schedule store persists and returns time_til_autostop_notify.
TemplateScheduleStore: schedule.NewAGPLTemplateScheduleStore(),
})
user := coderdtest.CreateFirstUser(t, client)
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) {
if timeTilAutostopNotify > 0 {
ctr.TimeTilAutostopNotifyMillis = ptr.Ref(timeTilAutostopNotify.Milliseconds())
}
})
ws := coderdtest.CreateWorkspace(t, client, template.ID)
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, ws.LatestBuild.ID)
workspace = coderdtest.MustWorkspace(t, client, ws.ID)
// The build must have a non-zero deadline for a reminder to ever fire.
require.Equal(t, codersdk.WorkspaceTransitionStart, workspace.LatestBuild.Transition)
require.NotZero(t, workspace.LatestBuild.Deadline)
return client, tickCh, statsCh, workspace
}
// failOnceEnqueuer fails its first Enqueue call and delegates every subsequent
// call to the wrapped enqueuer. It is used by the FailedEnqueueNotRetried
// subtest to verify that a failed reminder enqueue is not retried (the
// at-most-once guarantee); notificationstest.FakeEnqueuer.Enqueue always
// succeeds, so this wrapper is the only way to inject a send failure.
type failOnceEnqueuer struct {
notifications.Enqueuer
mu sync.Mutex
failed bool
}
func (f *failOnceEnqueuer) Enqueue(ctx context.Context, userID, templateID uuid.UUID, labels map[string]string, createdBy string, targets ...uuid.UUID) ([]uuid.UUID, error) {
f.mu.Lock()
defer f.mu.Unlock()
if !f.failed {
f.failed = true
return nil, xerrors.New("injected enqueue failure")
}
return f.Enqueuer.Enqueue(ctx, userID, templateID, labels, createdBy, targets...)
}
func TestExecutorAutostopReminder(t *testing.T) {
t.Parallel()
// Sent: a reminder is enqueued when a tick lands inside the lead window
// [deadline - ttl, deadline).
t.Run("Sent", func(t *testing.T) {
t.Parallel()
timeTilNotify := 30 * time.Minute
notifyEnq := &notificationstest.FakeEnqueuer{}
_, tickCh, statsCh, workspace := setupAutostopReminderWorkspace(t, timeTilNotify, notifyEnq)
deadline := workspace.LatestBuild.Deadline.Time
go func() {
// Halfway into the lead window.
tickCh <- deadline.Add(-timeTilNotify / 2)
close(tickCh)
}()
stats := testutil.TryReceive(testutil.Context(t, testutil.WaitShort), t, statsCh)
require.Len(t, stats.Errors, 0)
require.Len(t, stats.Transitions, 0)
sent := notifyEnq.Sent(notificationstest.WithTemplateID(notifications.TemplateWorkspaceAutostopReminder))
require.Len(t, sent, 1)
require.Equal(t, workspace.OwnerID, sent[0].UserID)
require.Equal(t, workspace.Name, sent[0].Labels["workspace"])
require.Equal(t, deadline.UTC().Format(time.RFC1123), sent[0].Labels["deadline"])
require.Contains(t, sent[0].Targets, workspace.ID)
require.Contains(t, sent[0].Targets, workspace.OwnerID)
require.Contains(t, sent[0].Targets, workspace.TemplateID)
require.Contains(t, sent[0].Targets, workspace.OrganizationID)
})
// NotBeforeWindow: no reminder when the tick precedes the lead window.
t.Run("NotBeforeWindow", func(t *testing.T) {
t.Parallel()
timeTilNotify := 30 * time.Minute
notifyEnq := &notificationstest.FakeEnqueuer{}
_, tickCh, statsCh, workspace := setupAutostopReminderWorkspace(t, timeTilNotify, notifyEnq)
deadline := workspace.LatestBuild.Deadline.Time
go func() {
// Well before the window opens.
tickCh <- deadline.Add(-2 * timeTilNotify)
close(tickCh)
}()
stats := testutil.TryReceive(testutil.Context(t, testutil.WaitShort), t, statsCh)
require.Len(t, stats.Errors, 0)
require.Empty(t, notifyEnq.Sent(notificationstest.WithTemplateID(notifications.TemplateWorkspaceAutostopReminder)))
})
// Disabled: time_til_autostop_notify of 0 (the default) never reminds.
t.Run("Disabled", func(t *testing.T) {
t.Parallel()
notifyEnq := &notificationstest.FakeEnqueuer{}
_, tickCh, statsCh, workspace := setupAutostopReminderWorkspace(t, 0, notifyEnq)
deadline := workspace.LatestBuild.Deadline.Time
go func() {
tickCh <- deadline.Add(-time.Minute)
close(tickCh)
}()
stats := testutil.TryReceive(testutil.Context(t, testutil.WaitShort), t, statsCh)
require.Len(t, stats.Errors, 0)
require.Empty(t, notifyEnq.Sent(notificationstest.WithTemplateID(notifications.TemplateWorkspaceAutostopReminder)))
})
// NoDuplicate: a second tick still inside the window does not re-notify
// because the idempotence marker was stamped.
t.Run("NoDuplicate", func(t *testing.T) {
t.Parallel()
timeTilNotify := 30 * time.Minute
notifyEnq := &notificationstest.FakeEnqueuer{}
_, tickCh, statsCh, workspace := setupAutostopReminderWorkspace(t, timeTilNotify, notifyEnq)
deadline := workspace.LatestBuild.Deadline.Time
// First tick: reminder fires. Receiving from statsCh acts as the
// per-tick barrier guaranteeing the enqueue already happened.
go func() {
tickCh <- deadline.Add(-timeTilNotify / 2)
}()
testutil.TryReceive(testutil.Context(t, testutil.WaitShort), t, statsCh)
require.Len(t, notifyEnq.Sent(notificationstest.WithTemplateID(notifications.TemplateWorkspaceAutostopReminder)), 1)
// Second tick still inside the window: no new reminder. Sent()
// accumulates across ticks, so a cumulative count still at 1 proves
// the duplicate was suppressed.
go func() {
tickCh <- deadline.Add(-timeTilNotify / 4)
close(tickCh)
}()
testutil.TryReceive(testutil.Context(t, testutil.WaitShort), t, statsCh)
require.Len(t, notifyEnq.Sent(notificationstest.WithTemplateID(notifications.TemplateWorkspaceAutostopReminder)), 1)
})
// DeadlineBumped: extending the deadline re-arms the marker, so a new
// reminder fires once the new deadline re-enters the window.
t.Run("DeadlineBumped", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
timeTilNotify := 30 * time.Minute
notifyEnq := &notificationstest.FakeEnqueuer{}
client, tickCh, statsCh, workspace := setupAutostopReminderWorkspace(t, timeTilNotify, notifyEnq)
deadline := workspace.LatestBuild.Deadline.Time
// First tick: reminder fires for the original deadline.
go func() {
tickCh <- deadline.Add(-timeTilNotify / 2)
}()
testutil.TryReceive(ctx, t, statsCh)
sent := notifyEnq.Sent(notificationstest.WithTemplateID(notifications.TemplateWorkspaceAutostopReminder))
require.Len(t, sent, 1)
require.Equal(t, deadline.UTC().Format(time.RFC1123), sent[0].Labels["deadline"])
// Move the deadline well into the future. The marker now differs from
// the build deadline, re-arming the reminder.
newDeadline := deadline.Add(2 * time.Hour)
require.NoError(t, client.PutExtendWorkspace(ctx, workspace.ID, codersdk.PutExtendWorkspaceRequest{
Deadline: newDeadline,
}))
// Second tick inside the new window fires another reminder. Sent()
// accumulates across ticks, so two total proves the second reminder
// fired; sent[1] carries the bumped deadline.
go func() {
tickCh <- newDeadline.Add(-timeTilNotify / 2)
close(tickCh)
}()
testutil.TryReceive(ctx, t, statsCh)
sent = notifyEnq.Sent(notificationstest.WithTemplateID(notifications.TemplateWorkspaceAutostopReminder))
require.Len(t, sent, 2)
require.Equal(t, newDeadline.UTC().Format(time.RFC1123), sent[1].Labels["deadline"])
})
// ExceedsLifetime: a time_til_autostop_notify larger than the
// workspace's remaining lifetime yields exactly one reminder, not one per
// tick.
t.Run("ExceedsLifetime", func(t *testing.T) {
t.Parallel()
// Far larger than the workspace's 8h TTL, so the lead window already
// includes "now" at build creation.
timeTilNotify := 100 * time.Hour
notifyEnq := &notificationstest.FakeEnqueuer{}
_, tickCh, statsCh, workspace := setupAutostopReminderWorkspace(t, timeTilNotify, notifyEnq)
deadline := workspace.LatestBuild.Deadline.Time
// First tick: a single reminder fires.
go func() {
tickCh <- deadline.Add(-time.Hour)
}()
testutil.TryReceive(testutil.Context(t, testutil.WaitShort), t, statsCh)
require.Len(t, notifyEnq.Sent(notificationstest.WithTemplateID(notifications.TemplateWorkspaceAutostopReminder)), 1)
// Second tick still before the deadline: no flood of reminders. Sent()
// accumulates across ticks, so a cumulative count still at 1 proves no
// duplicate fired.
go func() {
tickCh <- deadline.Add(-30 * time.Minute)
close(tickCh)
}()
testutil.TryReceive(testutil.Context(t, testutil.WaitShort), t, statsCh)
require.Len(t, notifyEnq.Sent(notificationstest.WithTemplateID(notifications.TemplateWorkspaceAutostopReminder)), 1)
})
// FailedEnqueueNotRetried pins the marker-before-enqueue / at-most-once
// guarantee: the marker is committed inside the transaction before the
// post-commit enqueue, so a failed enqueue on the first tick is NOT
// retried on a later tick even though the workspace is still inside the
// lead window. failOnceEnqueuer injects that single send failure;
// notificationstest.FakeEnqueuer.Enqueue always succeeds.
t.Run("FailedEnqueueNotRetried", func(t *testing.T) {
t.Parallel()
fake := &notificationstest.FakeEnqueuer{}
enq := &failOnceEnqueuer{Enqueuer: fake}
timeTilNotify := 2 * time.Hour
_, tickCh, statsCh, workspace := setupAutostopReminderWorkspace(t, timeTilNotify, enq)
deadline := workspace.LatestBuild.Deadline.Time
// Tick 1 inside the window: the enqueue fails. Because the marker is
// stamped before the enqueue, the failure only logs and nothing is
// sent.
go func() {
tickCh <- deadline.Add(-time.Hour)
}()
testutil.TryReceive(testutil.Context(t, testutil.WaitShort), t, statsCh)
require.Len(t, fake.Sent(notificationstest.WithTemplateID(notifications.TemplateWorkspaceAutostopReminder)), 0)
// Tick 2 still inside the window: the committed marker suppresses
// re-selection, so the failed reminder is NOT retried. A cumulative
// count still at 0 proves the at-most-once guarantee described at the
// enqueue block in lifecycle_executor.go.
go func() {
tickCh <- deadline.Add(-time.Hour + time.Minute)
close(tickCh)
}()
testutil.TryReceive(testutil.Context(t, testutil.WaitShort), t, statsCh)
require.Len(t, fake.Sent(notificationstest.WithTemplateID(notifications.TemplateWorkspaceAutostopReminder)), 0)
})
}
func mustProvisionWorkspace(t *testing.T, client *codersdk.Client, mut ...func(*codersdk.CreateWorkspaceRequest)) codersdk.Workspace {
t.Helper()
user := coderdtest.CreateFirstUser(t, client)
+24 -31
View File
@@ -21,7 +21,6 @@ import (
func TestValidate(t *testing.T) {
t.Parallel()
t.Skip("See https://github.com/coder/internal/issues/1602")
mustTime := func(layout string, value string) time.Time {
ti, err := time.Parse(layout, value)
@@ -36,19 +35,38 @@ func TestValidate(t *testing.T) {
vmID string
}{{
name: "regular",
payload: "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",
vmID: "bd8e7443-24a0-41f3-b949-8baf4fd1c573",
date: mustTime(time.RFC3339, "2023-02-01T00:00:00Z"),
payload: "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",
vmID: "dc18de58-292f-4794-9a5d-a4192bad0039",
// This cert uses intermediates:
// 1. Microsoft TLS G2 RSA CA OCSP 02 (expires 2029-06-03T20:03:00Z)
// 2. Microsoft TLS RSA Root G2 (expires 2029-06-19T23:59:59Z)
// It uses root:
// DigiCert Global Root G2 (expires 2038-01-15T12:00:00Z)
// So this test should be good until 2038 provided that we don't remove the above intermediates, and the
// root doesn't get removed from OS trust stores (would be very surprising and a huge security deal).
date: mustTime(time.RFC3339, "2026-06-25T00:00:00Z"),
}, {
name: "govcloud",
payload: "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",
vmID: "990878d4-068a-4ac4-9ee9-1231d2218ef2",
date: mustTime(time.RFC3339, "2023-04-01T00:00:00Z"),
// This cert uses intermediate:
// Microsoft Azure TLS Issuing CA 05 (expires 2024-06-27T23:59:59Z)
// It uses root:
// DigiCert Global Root G2 (expires 2038-01-15T12:00:00Z)
// So this test should be good until 2038 provided that we don't remove the above intermediates, and the
// root doesn't get removed from OS trust stores (would be very surprising and a huge security deal).
date: mustTime(time.RFC3339, "2023-04-01T00:00:00Z"),
}, {
name: "rsa",
payload: "MIILnwYJKoZIhvcNAQcCoIILkDCCC4wCAQExDzANBgkqhkiG9w0BAQsFADCCAUUGCSqGSIb3DQEHAaCCATYEggEyeyJsaWNlbnNlVHlwZSI6IiIsIm5vbmNlIjoiMjAyNDA0MjItMjMzMjQ1IiwicGxhbiI6eyJuYW1lIjoiIiwicHJvZHVjdCI6IiIsInB1Ymxpc2hlciI6IiJ9LCJza3UiOiIyMF8wNC1sdHMtZ2VuMiIsInN1YnNjcmlwdGlvbklkIjoiMDVlOGIyODUtNGNlMS00NmEzLWI0YzktZjUxYmE2N2Q2YWNjIiwidGltZVN0YW1wIjp7ImNyZWF0ZWRPbiI6IjA0LzIyLzI0IDE3OjMyOjQ1IC0wMDAwIiwiZXhwaXJlc09uIjoiMDQvMjIvMjQgMjM6MzI6NDUgLTAwMDAifSwidm1JZCI6Ijk2MGE0YjRhLWRhYjItNDRlZi05YjczLTc3NTMwNDNiNGYxNiJ9oIIIiDCCCIQwggZsoAMCAQICEzMAJtj/yBIW1kk+vsIAAAAm2P8wDQYJKoZIhvcNAQEMBQAwXTELMAkGA1UEBhMCVVMxHjAcBgNVBAoTFU1pY3Jvc29mdCBDb3Jwb3JhdGlvbjEuMCwGA1UEAxMlTWljcm9zb2Z0IEF6dXJlIFJTQSBUTFMgSXNzdWluZyBDQSAwODAeFw0yNDA0MTgwODM1MzdaFw0yNTA0MTMwODM1MzdaMGkxCzAJBgNVBAYTAlVTMQswCQYDVQQIEwJXQTEQMA4GA1UEBxMHUmVkbW9uZDEeMBwGA1UEChMVTWljcm9zb2Z0IENvcnBvcmF0aW9uMRswGQYDVQQDExJtZXRhZGF0YS5henVyZS5jb20wggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQD0T031XgxaebNQjKFQZ4BudeN+wOEHQoFq/x+cKSXM8HJrC2pF8y/ngSsuCLGt72M+30KxdbPHl56kd52uwDw1ZBrQO6Xw+GorRbtM4YQi+gLr8t9x+GUfuOX7E+5juidXax7la5ZhpVVLb3f+8NyxbphvEdFadXcgyQga1pl4v1U8elkbX3PPtEQXzwYotU+RU/ZTwXMYqfvJuaKwc4T2s083kaL3DwAfVxL0f6ey/MXuNQb4+ho15y9/f9gwMyzMDLlYChmY6cGSS4tsyrG5SrybE3jl8LZ1ZLVJ2fAIxbmJzBn1q+Eu4G6TZlnMDEsjznf7gqnP+n/o7N6l0sY1AgMBAAGjggQvMIIEKzCCAX4GCisGAQQB1nkCBAIEggFuBIIBagFoAHYAzxFW7tUufK/zh1vZaS6b6RpxZ0qwF+ysAdJbd87MOwgAAAGO8GIJ/QAABAMARzBFAiEAvJQ2mDRow9TMvLddWpYqNXLiehSFsj2+xUqh8yP/B8YCIBJjVoELj3kdVr3ceAuZFte9FH6sBsgeMsIgfndho6hRAHUAfVkeEuF4KnscYWd8Xv340IdcFKBOlZ65Ay/ZDowuebgAAAGO8GIK2AAABAMARjBEAiAxXD1R9yLASrpMh4ie0wn3AjCoSPniZ8virEVz8tKnkwIgWxGU9DjjQk7gPWYVBsiXP9t1WPJ6mNJ1UkmAw8iDdFoAdwBVgdTCFpA2AUrqC5tXPFPwwOQ4eHAlCBcvo6odBxPTDAAAAY7wYgrtAAAEAwBIMEYCIQCaSjdXbUhrDyPNsRqewp5UdVYABGQAIgNwfKsq/JpbmAIhAPy5qQ6H2enXwuKsorEZTwIkKIoMgLsWs4anx9lXTJMeMCcGCSsGAQQBgjcVCgQaMBgwCgYIKwYBBQUHAwIwCgYIKwYBBQUHAwEwPAYJKwYBBAGCNxUHBC8wLQYlKwYBBAGCNxUIh73XG4Hn60aCgZ0ujtAMh/DaHV2ChOVpgvOnPgIBZAIBJjCBtAYIKwYBBQUHAQEEgacwgaQwcwYIKwYBBQUHMAKGZ2h0dHA6Ly93d3cubWljcm9zb2Z0LmNvbS9wa2lvcHMvY2VydHMvTWljcm9zb2Z0JTIwQXp1cmUlMjBSU0ElMjBUTFMlMjBJc3N1aW5nJTIwQ0ElMjAwOCUyMC0lMjB4c2lnbi5jcnQwLQYIKwYBBQUHMAGGIWh0dHA6Ly9vbmVvY3NwLm1pY3Jvc29mdC5jb20vb2NzcDAdBgNVHQ4EFgQUnqRq3WHOZDoNmLD/arJg9RscxLowDgYDVR0PAQH/BAQDAgWgMDgGA1UdEQQxMC+CGWVhc3R1cy5tZXRhZGF0YS5henVyZS5jb22CEm1ldGFkYXRhLmF6dXJlLmNvbTAMBgNVHRMBAf8EAjAAMGoGA1UdHwRjMGEwX6BdoFuGWWh0dHA6Ly93d3cubWljcm9zb2Z0LmNvbS9wa2lvcHMvY3JsL01pY3Jvc29mdCUyMEF6dXJlJTIwUlNBJTIwVExTJTIwSXNzdWluZyUyMENBJTIwMDguY3JsMGYGA1UdIARfMF0wUQYMKwYBBAGCN0yDfQEBMEEwPwYIKwYBBQUHAgEWM2h0dHA6Ly93d3cubWljcm9zb2Z0LmNvbS9wa2lvcHMvRG9jcy9SZXBvc2l0b3J5Lmh0bTAIBgZngQwBAgIwHwYDVR0jBBgwFoAU9n4vvYCjSrJwW+vfmh/Y7cphgAcwHQYDVR0lBBYwFAYIKwYBBQUHAwIGCCsGAQUFBwMBMA0GCSqGSIb3DQEBDAUAA4ICAQB4FwyqZFVdmB9Hu+YUJOJrGUYRlXbnCmdXlLi5w2QRCf9RKIykGdv28dH1ezhXJUCj3jCVZMav4GaSl0dPUcTetfnc/UrwsmbGRIMubbGjCz75FcNz/kXy7E/jPeyJrxsuO/ijyZNUSy0EQF3NuhTJw/SfAQtXv48NmVFDM2QMMhMRLDfOV4CPcialAFACFQTt6LMdG2hlB972Bffl+BVPkUKDLj89xQRd/cyWYweYfPCsNLYLDml98rY3v4yVKAvv+l7IOuKOzhlOe9U1oPJK7AP7GZzojKrisPQt4HlP4zEmeUzJtL6RqGdHac7/lUMVPOniE/L+5gBDBsN3nOGJ/QE+bBsmfdn4ewuLj6/LCd/JhCZFDeyTvtuX43JWIr9e0UOtENCG3Ub4SuUftf58+NuedCaNMZW2jqrFvQl+sCX+v1kkxxmRphU7B8TZP0SHaBDqeIqHPNWD7eyn/7+VTY54wrwF1v5S6b5zpL1tjZ55c9wpVBT6m77mNuR/2l7/VSh/qL2LgKVVo06q+Qz2c0pIjOI+7FobLRNtb7C8SqkdwuT1b0vnZslA8ZUEtwUm5RHcGu66sg/hb4lGNZbAklxGeAR3uQju0OQN/Lj4kXiii737dci0lIpIKA92hUKybLrYCyZDhp5I6is0gTdm4+rxVEY1K39R3cF3U5thuzGCAZ8wggGbAgEBMHQwXTELMAkGA1UEBhMCVVMxHjAcBgNVBAoTFU1pY3Jvc29mdCBDb3Jwb3JhdGlvbjEuMCwGA1UEAxMlTWljcm9zb2Z0IEF6dXJlIFJTQSBUTFMgSXNzdWluZyBDQSAwOAITMwAm2P/IEhbWST6+wgAAACbY/zANBgkqhkiG9w0BAQsFADANBgkqhkiG9w0BAQEFAASCAQDRukRXI01EvAoF0J+C1aYCmjwAtMlnQr5fBKod8T75FhM+mTJ2GApCyc5H8hn7IDl8ki8DdKfLjipnuEvjknZcVkfrzE72R9Pu+C2ffKfrSsJmsBHPMEKBPtlzhexCYiPamMGdVg8HqX6mhQkjjavk1SY+ewZvyEeuq+RSQIBVL1lw0UOWv+txDKlu9v69skb1DQ2HSet0sejEb48vqGeN4TMSoQFNeBOzHDkEeoqXxtZqsUhMtQzbwrpAFcUREB8DaCOXcv1DOminJB3Q19bpuMQ/2+Fc3HJtTTWRV3+3b7VnQl/sUDzTjcWXvwjrLGKk3MSTcQ+1rJRlBzkOJ+aK",
vmID: "960a4b4a-dab2-44ef-9b73-7753043b4f16",
date: mustTime(time.RFC3339, "2024-04-22T17:32:44Z"),
// This cert uses intermediate:
// Microsoft Azure RSA TLS Issuing CA 08 (expires 2026-08-25T23:59:59Z)
// It uses root:
// DigiCert Global Root G2 (expires 2038-01-15T12:00:00Z)
// So this test should be good until 2038 provided that we don't remove the above intermediates, and the
// root doesn't get removed from OS trust stores (would be very surprising and a huge security deal).
date: mustTime(time.RFC3339, "2024-04-22T17:32:44Z"),
}} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
@@ -62,31 +80,6 @@ func TestValidate(t *testing.T) {
}
}
func TestExpiresSoon(t *testing.T) {
t.Parallel()
// TODO (@kylecarbs): It's unknown why Microsoft does not have new certificates live...
// The certificate is automatically fetched if it's not found in our database,
// so in a worst-case scenario expired certificates will only impact 100% airgapped users.
t.Skip()
const threshold = 1
certs, err := azureidentity.ParseCertificates()
require.NoError(t, err)
for _, cert := range certs {
expiresSoon := cert.NotAfter.Before(time.Now().AddDate(0, threshold, 0))
if expiresSoon {
t.Errorf("certificate expires within %d months %s: %s", threshold, cert.NotAfter, cert.Subject.CommonName)
} else {
url := "no issuing url"
if len(cert.IssuingCertificateURL) > 0 {
url = cert.IssuingCertificateURL[0]
}
t.Logf("certificate %q doesn't expire for a while (%s)", cert.Subject.CommonName, url)
}
}
}
func TestIsAllowedCertificateURL(t *testing.T) {
t.Parallel()
tests := []struct {
+6 -12
View File
@@ -124,15 +124,13 @@ func TestTracker_Track_Concurrent(t *testing.T) {
var wg sync.WaitGroup
for i := 0; i < numGoroutines; i++ {
wg.Add(1)
go func() {
defer wg.Done()
wg.Go(func() {
workspaceID := uuid.New()
ownerID := uuid.New()
for j := 0; j < requestsPerGoroutine; j++ {
tracker.Track(workspaceID, ownerID, 1, 1)
}
}()
})
}
wg.Wait()
@@ -507,22 +505,18 @@ func TestTracker_ConcurrentFlushAndTrack(t *testing.T) {
var wg sync.WaitGroup
// Goroutine 1: Continuously track.
wg.Add(1)
go func() {
defer wg.Done()
wg.Go(func() {
for i := 0; i < numOperations; i++ {
tracker.Track(uuid.New(), uuid.New(), 1, 1)
}
}()
})
// Goroutine 2: Continuously flush.
wg.Add(1)
go func() {
defer wg.Done()
wg.Go(func() {
for i := 0; i < numOperations; i++ {
_ = tracker.FlushToDB(ctx, db, replicaID)
}
}()
})
wg.Wait()
+4
View File
@@ -338,6 +338,10 @@ type Options struct {
// @securitydefinitions.apiKey CoderSessionToken
// @in header
// @name Coder-Session-Token
// @securitydefinitions.apiKey AIGatewayKey
// @in header
// @name X-AI-Governance-Gateway-Key
// New constructs a Coder API handler.
func New(options *Options) *API {
if options == nil {
+5
View File
@@ -370,6 +370,11 @@ func assertSecurityDefined(t *testing.T, comment SwaggerComment) {
comment.router == "/api/v2/init-script/{os}/{arch}" {
return // endpoints do not require authorization
}
if comment.router == "/api/v2/ai-gateway/serve" {
assert.Equal(t, "AIGatewayKey", comment.security, "@Security must be AIGatewayKey")
return
}
assert.Containsf(t, authorizedSecurityTags, comment.security, "@Security must be either of these options: %v", authorizedSecurityTags)
}
+20 -1
View File
@@ -5,6 +5,7 @@ import (
"crypto/rand"
"database/sql"
"encoding/hex"
"slices"
"time"
"golang.org/x/xerrors"
@@ -27,6 +28,24 @@ const (
DefaultKeyDuration = time.Hour * 24 * 30
)
// defaultRotatedFeatures are the crypto key features the rotator manages. It
// intentionally excludes features that are gated behind an experiment or
// deployment flag so that a dormant feature's enum value does not cause the
// rotator to mint keys it has no generator for. Gated features are opted in by
// the caller that owns their generator.
var defaultRotatedFeatures = []database.CryptoKeyFeature{
database.CryptoKeyFeatureWorkspaceAppsToken,
database.CryptoKeyFeatureWorkspaceAppsAPIKey,
database.CryptoKeyFeatureOIDCConvert,
database.CryptoKeyFeatureTailnetResume,
}
// DefaultRotatedFeatures returns the crypto key features the rotator manages by
// default. It excludes experiment-gated features such as the NATS CA.
func DefaultRotatedFeatures() []database.CryptoKeyFeature {
return slices.Clone(defaultRotatedFeatures)
}
// rotator is responsible for rotating keys in the database.
type rotator struct {
db database.Store
@@ -62,7 +81,7 @@ func StartRotator(ctx context.Context, logger slog.Logger, db database.Store, op
logger: logger.Named("keyrotator"),
clock: quartz.NewReal(),
keyDuration: DefaultKeyDuration,
features: database.AllCryptoKeyFeatureValues(),
features: defaultRotatedFeatures,
}
for _, opt := range opts {
+2 -2
View File
@@ -358,7 +358,7 @@ func Test_rotateKeys(t *testing.T) {
keyDuration: keyDuration,
clock: clock,
logger: logger,
features: database.AllCryptoKeyFeatureValues(),
features: defaultRotatedFeatures,
}
now := dbnow(clock)
@@ -409,7 +409,7 @@ func Test_rotateKeys(t *testing.T) {
require.NoError(t, err)
require.Len(t, keys, 5)
kbf, err := keysByFeature(keys, database.AllCryptoKeyFeatureValues())
kbf, err := keysByFeature(keys, defaultRotatedFeatures)
require.NoError(t, err)
// No actions on OIDC convert.
+3 -3
View File
@@ -37,7 +37,7 @@ func TestRotator(t *testing.T) {
// are as expected.
dbkeys, err = db.GetCryptoKeys(ctx)
require.NoError(t, err)
require.Len(t, dbkeys, len(database.AllCryptoKeyFeatureValues()))
require.Len(t, dbkeys, len(cryptokeys.DefaultRotatedFeatures()))
requireContainsAllFeatures(t, dbkeys)
})
@@ -64,7 +64,7 @@ func TestRotator(t *testing.T) {
cryptokeys.StartRotator(ctx, logger, db, cryptokeys.WithClock(clock))
initialKeyLen := len(database.AllCryptoKeyFeatureValues())
initialKeyLen := len(cryptokeys.DefaultRotatedFeatures())
// Fetch the keys from the database and ensure they
// are as expected.
dbkeys, err := db.GetCryptoKeys(ctx)
@@ -113,7 +113,7 @@ func requireContainsAllFeatures(t *testing.T, keys []database.CryptoKey) {
for _, key := range keys {
features[key.Feature] = true
}
for _, feature := range database.AllCryptoKeyFeatureValues() {
for _, feature := range cryptokeys.DefaultRotatedFeatures() {
require.True(t, features[feature])
}
}
+41 -2
View File
@@ -486,6 +486,7 @@ var (
rbac.ResourceOauth2AppSecret.Type: {policy.ActionCreate, policy.ActionRead, policy.ActionUpdate, policy.ActionDelete},
rbac.ResourceChat.Type: {policy.ActionCreate, policy.ActionRead, policy.ActionUpdate, policy.ActionDelete},
rbac.ResourceAIProvider.Type: {policy.ActionCreate, policy.ActionRead, policy.ActionUpdate, policy.ActionDelete},
rbac.ResourceAIGatewayKey.Type: {policy.ActionRead, policy.ActionUpdate},
}),
User: []rbac.Permission{},
ByOrgID: map[string]rbac.OrgPermissions{},
@@ -2753,6 +2754,16 @@ func (q *querier) GetAIBridgeUserPromptsByInterceptionID(ctx context.Context, in
return q.db.GetAIBridgeUserPromptsByInterceptionID(ctx, interceptionID)
}
// Authenticates a standalone AI Gateway replica by its hashed key secret, returning the matched key.
func (q *querier) GetAIGatewayKeyByHashedSecret(ctx context.Context, hashedSecret []byte) (database.AIGatewayKey, error) {
// Standalone AI Gateway has no Coder identity, so this runs under the
// system actor reading the AI Gateway key it authenticates against.
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceAIGatewayKey); err != nil {
return database.AIGatewayKey{}, err
}
return q.db.GetAIGatewayKeyByHashedSecret(ctx, hashedSecret)
}
func (q *querier) GetAIModelPriceByProviderModel(ctx context.Context, arg database.GetAIModelPriceByProviderModelParams) (database.AIModelPrice, error) {
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceAiModelPrice); err != nil {
return database.AIModelPrice{}, err
@@ -5686,8 +5697,8 @@ func (q *querier) GetWorkspacesByTemplateID(ctx context.Context, templateID uuid
return q.db.GetWorkspacesByTemplateID(ctx, templateID)
}
func (q *querier) GetWorkspacesEligibleForTransition(ctx context.Context, now time.Time) ([]database.GetWorkspacesEligibleForTransitionRow, error) {
return q.db.GetWorkspacesEligibleForTransition(ctx, now)
func (q *querier) GetWorkspacesEligibleForLifecycleAction(ctx context.Context, now time.Time) ([]database.GetWorkspacesEligibleForLifecycleActionRow, error) {
return q.db.GetWorkspacesEligibleForLifecycleAction(ctx, now)
}
func (q *querier) GetWorkspacesForWorkspaceMetrics(ctx context.Context) ([]database.GetWorkspacesForWorkspaceMetricsRow, error) {
@@ -7022,6 +7033,16 @@ func (q *querier) UpdateAIBridgeInterceptionEnded(ctx context.Context, params da
return q.db.UpdateAIBridgeInterceptionEnded(ctx, params)
}
// Records heartbeat liveness for a key used in active DRPC session between coderd and standalone AI Gateway.
func (q *querier) UpdateAIGatewayKeyLastHeartbeatAt(ctx context.Context, id uuid.UUID) (int64, error) {
// Standalone AI Gateway has no Coder identity, so this runs under the
// system actor recording connection liveness on the AI Gateway key.
if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceAIGatewayKey); err != nil {
return 0, err
}
return q.db.UpdateAIGatewayKeyLastHeartbeatAt(ctx, id)
}
func (q *querier) UpdateAIProvider(ctx context.Context, arg database.UpdateAIProviderParams) (database.AIProvider, error) {
if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceAIProvider); err != nil {
return database.AIProvider{}, err
@@ -8414,6 +8435,24 @@ func (q *querier) UpdateWorkspaceBuildFlagsByID(ctx context.Context, arg databas
return q.db.UpdateWorkspaceBuildFlagsByID(ctx, arg)
}
func (q *querier) UpdateWorkspaceBuildNotifiedAutostopDeadline(ctx context.Context, arg database.UpdateWorkspaceBuildNotifiedAutostopDeadlineParams) error {
build, err := q.db.GetWorkspaceBuildByID(ctx, arg.ID)
if err != nil {
return err
}
workspace, err := q.db.GetWorkspaceByID(ctx, build.WorkspaceID)
if err != nil {
return err
}
err = q.authorizeContext(ctx, policy.ActionUpdate, workspace.RBACObject())
if err != nil {
return err
}
return q.db.UpdateWorkspaceBuildNotifiedAutostopDeadline(ctx, arg)
}
func (q *querier) UpdateWorkspaceBuildProvisionerStateByID(ctx context.Context, arg database.UpdateWorkspaceBuildProvisionerStateByIDParams) error {
if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceSystem); err != nil {
return err
+22 -2
View File
@@ -4142,6 +4142,15 @@ func (s *MethodTestSuite) TestWorkspace() {
dbm.EXPECT().UpdateWorkspaceBuildDeadlineByID(gomock.Any(), arg).Return(nil).AnyTimes()
check.Args(arg).Asserts(w, policy.ActionUpdate)
}))
s.Run("UpdateWorkspaceBuildNotifiedAutostopDeadline", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
w := testutil.Fake(s.T(), faker, database.Workspace{})
b := testutil.Fake(s.T(), faker, database.WorkspaceBuild{WorkspaceID: w.ID})
arg := database.UpdateWorkspaceBuildNotifiedAutostopDeadlineParams{ID: b.ID, NotifiedAutostopDeadline: b.Deadline}
dbm.EXPECT().GetWorkspaceBuildByID(gomock.Any(), b.ID).Return(b, nil).AnyTimes()
dbm.EXPECT().GetWorkspaceByID(gomock.Any(), w.ID).Return(w, nil).AnyTimes()
dbm.EXPECT().UpdateWorkspaceBuildNotifiedAutostopDeadline(gomock.Any(), arg).Return(nil).AnyTimes()
check.Args(arg).Asserts(w, policy.ActionUpdate)
}))
s.Run("UpdateWorkspaceBuildFlagsByID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
u := testutil.Fake(s.T(), faker, database.User{})
o := testutil.Fake(s.T(), faker, database.Organization{})
@@ -5289,9 +5298,9 @@ func (s *MethodTestSuite) TestSystemFunctions() {
dbm.EXPECT().GetWorkspacesByTemplateID(gomock.Any(), id).Return([]database.WorkspaceTable{}, nil).AnyTimes()
check.Args(id).Asserts(rbac.ResourceSystem, policy.ActionRead)
}))
s.Run("GetWorkspacesEligibleForTransition", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
s.Run("GetWorkspacesEligibleForLifecycleAction", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
t := time.Time{}
dbm.EXPECT().GetWorkspacesEligibleForTransition(gomock.Any(), t).Return([]database.GetWorkspacesEligibleForTransitionRow{}, nil).AnyTimes()
dbm.EXPECT().GetWorkspacesEligibleForLifecycleAction(gomock.Any(), t).Return([]database.GetWorkspacesEligibleForLifecycleActionRow{}, nil).AnyTimes()
check.Args(t).Asserts()
}))
s.Run("InsertTemplateVersionVariable", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
@@ -6959,6 +6968,17 @@ func (s *MethodTestSuite) TestAIBridge() {
dbm.EXPECT().DeleteAIGatewayKey(gomock.Any(), id).Return(database.DeleteAIGatewayKeyRow{}, nil).AnyTimes()
check.Args(id).Asserts(rbac.ResourceAIGatewayKey, policy.ActionDelete).Returns(database.DeleteAIGatewayKeyRow{})
}))
s.Run("GetAIGatewayKeyByHashedSecret", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
hashedSecret := []byte("hashed-secret")
key := database.AIGatewayKey{ID: uuid.New(), HashedSecret: hashedSecret}
dbm.EXPECT().GetAIGatewayKeyByHashedSecret(gomock.Any(), hashedSecret).Return(key, nil).AnyTimes()
check.Args(hashedSecret).Asserts(rbac.ResourceAIGatewayKey, policy.ActionRead).Returns(key)
}))
s.Run("UpdateAIGatewayKeyLastHeartbeatAt", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
id := uuid.New()
dbm.EXPECT().UpdateAIGatewayKeyLastHeartbeatAt(gomock.Any(), id).Return(int64(1), nil).AnyTimes()
check.Args(id).Asserts(rbac.ResourceAIGatewayKey, policy.ActionUpdate).Returns(int64(1))
}))
}
func (s *MethodTestSuite) TestTelemetry() {
+28 -4
View File
@@ -1130,6 +1130,14 @@ func (m queryMetricsStore) GetAIBridgeUserPromptsByInterceptionID(ctx context.Co
return r0, r1
}
func (m queryMetricsStore) GetAIGatewayKeyByHashedSecret(ctx context.Context, hashedSecret []byte) (database.AIGatewayKey, error) {
start := time.Now()
r0, r1 := m.s.GetAIGatewayKeyByHashedSecret(ctx, hashedSecret)
m.queryLatencies.WithLabelValues("GetAIGatewayKeyByHashedSecret").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetAIGatewayKeyByHashedSecret").Inc()
return r0, r1
}
func (m queryMetricsStore) GetAIModelPriceByProviderModel(ctx context.Context, arg database.GetAIModelPriceByProviderModelParams) (database.AIModelPrice, error) {
start := time.Now()
r0, r1 := m.s.GetAIModelPriceByProviderModel(ctx, arg)
@@ -3858,11 +3866,11 @@ func (m queryMetricsStore) GetWorkspacesByTemplateID(ctx context.Context, templa
return r0, r1
}
func (m queryMetricsStore) GetWorkspacesEligibleForTransition(ctx context.Context, now time.Time) ([]database.GetWorkspacesEligibleForTransitionRow, error) {
func (m queryMetricsStore) GetWorkspacesEligibleForLifecycleAction(ctx context.Context, now time.Time) ([]database.GetWorkspacesEligibleForLifecycleActionRow, error) {
start := time.Now()
r0, r1 := m.s.GetWorkspacesEligibleForTransition(ctx, now)
m.queryLatencies.WithLabelValues("GetWorkspacesEligibleForTransition").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetWorkspacesEligibleForTransition").Inc()
r0, r1 := m.s.GetWorkspacesEligibleForLifecycleAction(ctx, now)
m.queryLatencies.WithLabelValues("GetWorkspacesEligibleForLifecycleAction").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetWorkspacesEligibleForLifecycleAction").Inc()
return r0, r1
}
@@ -5042,6 +5050,14 @@ func (m queryMetricsStore) UpdateAIBridgeInterceptionEnded(ctx context.Context,
return r0, r1
}
func (m queryMetricsStore) UpdateAIGatewayKeyLastHeartbeatAt(ctx context.Context, id uuid.UUID) (int64, error) {
start := time.Now()
r0, r1 := m.s.UpdateAIGatewayKeyLastHeartbeatAt(ctx, id)
m.queryLatencies.WithLabelValues("UpdateAIGatewayKeyLastHeartbeatAt").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "UpdateAIGatewayKeyLastHeartbeatAt").Inc()
return r0, r1
}
func (m queryMetricsStore) UpdateAIProvider(ctx context.Context, arg database.UpdateAIProviderParams) (database.AIProvider, error) {
start := time.Now()
r0, r1 := m.s.UpdateAIProvider(ctx, arg)
@@ -5946,6 +5962,14 @@ func (m queryMetricsStore) UpdateWorkspaceBuildFlagsByID(ctx context.Context, ar
return r0
}
func (m queryMetricsStore) UpdateWorkspaceBuildNotifiedAutostopDeadline(ctx context.Context, arg database.UpdateWorkspaceBuildNotifiedAutostopDeadlineParams) error {
start := time.Now()
r0 := m.s.UpdateWorkspaceBuildNotifiedAutostopDeadline(ctx, arg)
m.queryLatencies.WithLabelValues("UpdateWorkspaceBuildNotifiedAutostopDeadline").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "UpdateWorkspaceBuildNotifiedAutostopDeadline").Inc()
return r0
}
func (m queryMetricsStore) UpdateWorkspaceBuildProvisionerStateByID(ctx context.Context, arg database.UpdateWorkspaceBuildProvisionerStateByIDParams) error {
start := time.Now()
r0 := m.s.UpdateWorkspaceBuildProvisionerStateByID(ctx, arg)
+51 -7
View File
@@ -1947,6 +1947,21 @@ func (mr *MockStoreMockRecorder) GetAIBridgeUserPromptsByInterceptionID(ctx, int
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAIBridgeUserPromptsByInterceptionID", reflect.TypeOf((*MockStore)(nil).GetAIBridgeUserPromptsByInterceptionID), ctx, interceptionID)
}
// GetAIGatewayKeyByHashedSecret mocks base method.
func (m *MockStore) GetAIGatewayKeyByHashedSecret(ctx context.Context, hashedSecret []byte) (database.AIGatewayKey, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetAIGatewayKeyByHashedSecret", ctx, hashedSecret)
ret0, _ := ret[0].(database.AIGatewayKey)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetAIGatewayKeyByHashedSecret indicates an expected call of GetAIGatewayKeyByHashedSecret.
func (mr *MockStoreMockRecorder) GetAIGatewayKeyByHashedSecret(ctx, hashedSecret any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAIGatewayKeyByHashedSecret", reflect.TypeOf((*MockStore)(nil).GetAIGatewayKeyByHashedSecret), ctx, hashedSecret)
}
// GetAIModelPriceByProviderModel mocks base method.
func (m *MockStore) GetAIModelPriceByProviderModel(ctx context.Context, arg database.GetAIModelPriceByProviderModelParams) (database.AIModelPrice, error) {
m.ctrl.T.Helper()
@@ -7212,19 +7227,19 @@ func (mr *MockStoreMockRecorder) GetWorkspacesByTemplateID(ctx, templateID any)
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetWorkspacesByTemplateID", reflect.TypeOf((*MockStore)(nil).GetWorkspacesByTemplateID), ctx, templateID)
}
// GetWorkspacesEligibleForTransition mocks base method.
func (m *MockStore) GetWorkspacesEligibleForTransition(ctx context.Context, now time.Time) ([]database.GetWorkspacesEligibleForTransitionRow, error) {
// GetWorkspacesEligibleForLifecycleAction mocks base method.
func (m *MockStore) GetWorkspacesEligibleForLifecycleAction(ctx context.Context, now time.Time) ([]database.GetWorkspacesEligibleForLifecycleActionRow, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetWorkspacesEligibleForTransition", ctx, now)
ret0, _ := ret[0].([]database.GetWorkspacesEligibleForTransitionRow)
ret := m.ctrl.Call(m, "GetWorkspacesEligibleForLifecycleAction", ctx, now)
ret0, _ := ret[0].([]database.GetWorkspacesEligibleForLifecycleActionRow)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetWorkspacesEligibleForTransition indicates an expected call of GetWorkspacesEligibleForTransition.
func (mr *MockStoreMockRecorder) GetWorkspacesEligibleForTransition(ctx, now any) *gomock.Call {
// GetWorkspacesEligibleForLifecycleAction indicates an expected call of GetWorkspacesEligibleForLifecycleAction.
func (mr *MockStoreMockRecorder) GetWorkspacesEligibleForLifecycleAction(ctx, now any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetWorkspacesEligibleForTransition", reflect.TypeOf((*MockStore)(nil).GetWorkspacesEligibleForTransition), ctx, now)
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetWorkspacesEligibleForLifecycleAction", reflect.TypeOf((*MockStore)(nil).GetWorkspacesEligibleForLifecycleAction), ctx, now)
}
// GetWorkspacesForWorkspaceMetrics mocks base method.
@@ -9503,6 +9518,21 @@ func (mr *MockStoreMockRecorder) UpdateAIBridgeInterceptionEnded(ctx, arg any) *
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateAIBridgeInterceptionEnded", reflect.TypeOf((*MockStore)(nil).UpdateAIBridgeInterceptionEnded), ctx, arg)
}
// UpdateAIGatewayKeyLastHeartbeatAt mocks base method.
func (m *MockStore) UpdateAIGatewayKeyLastHeartbeatAt(ctx context.Context, id uuid.UUID) (int64, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "UpdateAIGatewayKeyLastHeartbeatAt", ctx, id)
ret0, _ := ret[0].(int64)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// UpdateAIGatewayKeyLastHeartbeatAt indicates an expected call of UpdateAIGatewayKeyLastHeartbeatAt.
func (mr *MockStoreMockRecorder) UpdateAIGatewayKeyLastHeartbeatAt(ctx, id any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateAIGatewayKeyLastHeartbeatAt", reflect.TypeOf((*MockStore)(nil).UpdateAIGatewayKeyLastHeartbeatAt), ctx, id)
}
// UpdateAIProvider mocks base method.
func (m *MockStore) UpdateAIProvider(ctx context.Context, arg database.UpdateAIProviderParams) (database.AIProvider, error) {
m.ctrl.T.Helper()
@@ -11151,6 +11181,20 @@ func (mr *MockStoreMockRecorder) UpdateWorkspaceBuildFlagsByID(ctx, arg any) *go
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateWorkspaceBuildFlagsByID", reflect.TypeOf((*MockStore)(nil).UpdateWorkspaceBuildFlagsByID), ctx, arg)
}
// UpdateWorkspaceBuildNotifiedAutostopDeadline mocks base method.
func (m *MockStore) UpdateWorkspaceBuildNotifiedAutostopDeadline(ctx context.Context, arg database.UpdateWorkspaceBuildNotifiedAutostopDeadlineParams) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "UpdateWorkspaceBuildNotifiedAutostopDeadline", ctx, arg)
ret0, _ := ret[0].(error)
return ret0
}
// UpdateWorkspaceBuildNotifiedAutostopDeadline indicates an expected call of UpdateWorkspaceBuildNotifiedAutostopDeadline.
func (mr *MockStoreMockRecorder) UpdateWorkspaceBuildNotifiedAutostopDeadline(ctx, arg any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateWorkspaceBuildNotifiedAutostopDeadline", reflect.TypeOf((*MockStore)(nil).UpdateWorkspaceBuildNotifiedAutostopDeadline), ctx, arg)
}
// UpdateWorkspaceBuildProvisionerStateByID mocks base method.
func (m *MockStore) UpdateWorkspaceBuildProvisionerStateByID(ctx context.Context, arg database.UpdateWorkspaceBuildProvisionerStateByIDParams) error {
m.ctrl.T.Helper()
+5 -3
View File
@@ -257,7 +257,8 @@ CREATE TYPE api_key_scope AS ENUM (
'ai_gateway_key:*',
'ai_gateway_key:create',
'ai_gateway_key:delete',
'ai_gateway_key:read'
'ai_gateway_key:read',
'ai_gateway_key:update'
);
CREATE TYPE app_sharing_level AS ENUM (
@@ -373,7 +374,8 @@ CREATE TYPE crypto_key_feature AS ENUM (
'workspace_apps_token',
'workspace_apps_api_key',
'oidc_convert',
'tailnet_resume'
'tailnet_resume',
'nats_ca'
);
CREATE TYPE display_app AS ENUM (
@@ -1439,7 +1441,7 @@ CREATE TABLE ai_gateway_keys (
name text NOT NULL,
secret_prefix character varying(11) NOT NULL,
hashed_secret bytea NOT NULL,
last_used_at timestamp with time zone,
last_heartbeat_at timestamp with time zone,
CONSTRAINT ai_gateway_keys_hashed_secret_check CHECK ((length(hashed_secret) > 0)),
CONSTRAINT ai_gateway_keys_name_check CHECK (((length(name) <= 64) AND (name ~ '^[a-z0-9]+(-[a-z0-9]+)*$'::text))),
CONSTRAINT ai_gateway_keys_secret_prefix_check CHECK ((length((secret_prefix)::text) = 11))
@@ -0,0 +1,2 @@
-- Enum additions to api_key_scope are intentionally not reverted because
-- Postgres cannot drop enum values safely.
@@ -0,0 +1 @@
ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'ai_gateway_key:update';
@@ -0,0 +1,2 @@
ALTER TABLE ai_gateway_keys
RENAME COLUMN last_heartbeat_at TO last_used_at;
@@ -0,0 +1,2 @@
ALTER TABLE ai_gateway_keys
RENAME COLUMN last_used_at TO last_heartbeat_at;
@@ -0,0 +1,16 @@
DELETE FROM crypto_keys WHERE feature = 'nats_ca';
CREATE TYPE old_crypto_key_feature AS ENUM (
'workspace_apps_token',
'workspace_apps_api_key',
'oidc_convert',
'tailnet_resume'
);
ALTER TABLE crypto_keys
ALTER COLUMN feature TYPE old_crypto_key_feature
USING (feature::text::old_crypto_key_feature);
DROP TYPE crypto_key_feature;
ALTER TYPE old_crypto_key_feature RENAME TO crypto_key_feature;
@@ -0,0 +1 @@
ALTER TYPE crypto_key_feature ADD VALUE IF NOT EXISTS 'nats_ca';
+11 -5
View File
@@ -386,6 +386,7 @@ const (
ApiKeyScopeAIGatewayKeyCreate APIKeyScope = "ai_gateway_key:create"
ApiKeyScopeAIGatewayKeyDelete APIKeyScope = "ai_gateway_key:delete"
ApiKeyScopeAIGatewayKeyRead APIKeyScope = "ai_gateway_key:read"
ApiKeyScopeAIGatewayKeyUpdate APIKeyScope = "ai_gateway_key:update"
)
func (e *APIKeyScope) Scan(src interface{}) error {
@@ -654,7 +655,8 @@ func (e APIKeyScope) Valid() bool {
ApiKeyScopeAIGatewayKey,
ApiKeyScopeAIGatewayKeyCreate,
ApiKeyScopeAIGatewayKeyDelete,
ApiKeyScopeAIGatewayKeyRead:
ApiKeyScopeAIGatewayKeyRead,
ApiKeyScopeAIGatewayKeyUpdate:
return true
}
return false
@@ -892,6 +894,7 @@ func AllAPIKeyScopeValues() []APIKeyScope {
ApiKeyScopeAIGatewayKeyCreate,
ApiKeyScopeAIGatewayKeyDelete,
ApiKeyScopeAIGatewayKeyRead,
ApiKeyScopeAIGatewayKeyUpdate,
}
}
@@ -1884,6 +1887,7 @@ const (
CryptoKeyFeatureWorkspaceAppsAPIKey CryptoKeyFeature = "workspace_apps_api_key"
CryptoKeyFeatureOIDCConvert CryptoKeyFeature = "oidc_convert"
CryptoKeyFeatureTailnetResume CryptoKeyFeature = "tailnet_resume"
CryptoKeyFeatureNATSCA CryptoKeyFeature = "nats_ca"
)
func (e *CryptoKeyFeature) Scan(src interface{}) error {
@@ -1926,7 +1930,8 @@ func (e CryptoKeyFeature) Valid() bool {
case CryptoKeyFeatureWorkspaceAppsToken,
CryptoKeyFeatureWorkspaceAppsAPIKey,
CryptoKeyFeatureOIDCConvert,
CryptoKeyFeatureTailnetResume:
CryptoKeyFeatureTailnetResume,
CryptoKeyFeatureNATSCA:
return true
}
return false
@@ -1938,6 +1943,7 @@ func AllCryptoKeyFeatureValues() []CryptoKeyFeature {
CryptoKeyFeatureWorkspaceAppsAPIKey,
CryptoKeyFeatureOIDCConvert,
CryptoKeyFeatureTailnetResume,
CryptoKeyFeatureNATSCA,
}
}
@@ -4615,9 +4621,9 @@ type AIGatewayKey struct {
CreatedAt time.Time `db:"created_at" json:"created_at"`
Name string `db:"name" json:"name"`
// Public token prefix for display and audit correlation. Auth uses hashed_secret.
SecretPrefix string `db:"secret_prefix" json:"secret_prefix"`
HashedSecret []byte `db:"hashed_secret" json:"hashed_secret"`
LastUsedAt sql.NullTime `db:"last_used_at" json:"last_used_at"`
SecretPrefix string `db:"secret_prefix" json:"secret_prefix"`
HashedSecret []byte `db:"hashed_secret" json:"hashed_secret"`
LastHeartbeatAt sql.NullTime `db:"last_heartbeat_at" json:"last_heartbeat_at"`
}
// Per-model token prices used by AI Bridge to compute interception cost.
+19 -1
View File
@@ -301,6 +301,10 @@ type sqlcQuerier interface {
GetAIBridgeTokenUsagesByInterceptionID(ctx context.Context, interceptionID uuid.UUID) ([]AIBridgeTokenUsage, error)
GetAIBridgeToolUsagesByInterceptionID(ctx context.Context, interceptionID uuid.UUID) ([]AIBridgeToolUsage, error)
GetAIBridgeUserPromptsByInterceptionID(ctx context.Context, interceptionID uuid.UUID) ([]AIBridgeUserPrompt, error)
// Authenticates a standalone AI Gateway replica by its hashed key secret,
// returning the matched key. The lookup is an exact match on a unique index,
// so a returned row is itself proof the secret is valid.
GetAIGatewayKeyByHashedSecret(ctx context.Context, hashedSecret []byte) (AIGatewayKey, error)
GetAIModelPriceByProviderModel(ctx context.Context, arg GetAIModelPriceByProviderModelParams) (AIModelPrice, error)
GetAIProviderByID(ctx context.Context, id uuid.UUID) (AIProvider, error)
// Lock the provider row until the model-config write completes. The
@@ -973,7 +977,11 @@ type sqlcQuerier interface {
GetWorkspaces(ctx context.Context, arg GetWorkspacesParams) ([]GetWorkspacesRow, error)
GetWorkspacesAndAgentsByOwnerID(ctx context.Context, ownerID uuid.UUID) ([]GetWorkspacesAndAgentsByOwnerIDRow, error)
GetWorkspacesByTemplateID(ctx context.Context, templateID uuid.UUID) ([]WorkspaceTable, error)
GetWorkspacesEligibleForTransition(ctx context.Context, now time.Time) ([]GetWorkspacesEligibleForTransitionRow, error)
// Returns workspaces the lifecycle executor must act on this tick. An
// "action" is a state transition (autostart/autostop/dormancy/delete), a
// dormancy mark (which has no build transition), or a one-time autostop
// reminder notification (which only stamps a marker, no transition).
GetWorkspacesEligibleForLifecycleAction(ctx context.Context, now time.Time) ([]GetWorkspacesEligibleForLifecycleActionRow, error)
GetWorkspacesForWorkspaceMetrics(ctx context.Context) ([]GetWorkspacesForWorkspaceMetricsRow, error)
// Reports whether the given file is referenced as cached module files by any
// template version in the given organization. Used to authorize provisioner
@@ -1303,6 +1311,10 @@ type sqlcQuerier interface {
UnpinChatByID(ctx context.Context, id uuid.UUID) error
UnsetDefaultChatModelConfigs(ctx context.Context) error
UpdateAIBridgeInterceptionEnded(ctx context.Context, arg UpdateAIBridgeInterceptionEndedParams) (AIBridgeInterception, error)
// Records heartbeat liveness for an active Gateway DRPC session. The database sets the
// timestamp so it stays consistent regardless of clock drift between API
// replicas.
UpdateAIGatewayKeyLastHeartbeatAt(ctx context.Context, id uuid.UUID) (int64, error)
UpdateAIProvider(ctx context.Context, arg UpdateAIProviderParams) (AIProvider, error)
UpdateAPIKeyByID(ctx context.Context, arg UpdateAPIKeyByIDParams) error
UpdateChatACLByID(ctx context.Context, arg UpdateChatACLByIDParams) error
@@ -1472,6 +1484,12 @@ type sqlcQuerier interface {
UpdateWorkspaceBuildCostByID(ctx context.Context, arg UpdateWorkspaceBuildCostByIDParams) error
UpdateWorkspaceBuildDeadlineByID(ctx context.Context, arg UpdateWorkspaceBuildDeadlineByIDParams) error
UpdateWorkspaceBuildFlagsByID(ctx context.Context, arg UpdateWorkspaceBuildFlagsByIDParams) error
// Stamps the deadline value that an autostop reminder was last sent for. Once
// this equals the build's deadline the reminder is considered handled and the
// lifecycle executor will not send another for this deadline, which makes the
// reminder idempotent and HA-safe. It re-arms automatically when the deadline
// changes (e.g. an activity bump).
UpdateWorkspaceBuildNotifiedAutostopDeadline(ctx context.Context, arg UpdateWorkspaceBuildNotifiedAutostopDeadlineParams) error
UpdateWorkspaceBuildProvisionerStateByID(ctx context.Context, arg UpdateWorkspaceBuildProvisionerStateByIDParams) error
UpdateWorkspaceDeletedByID(ctx context.Context, arg UpdateWorkspaceDeletedByIDParams) error
UpdateWorkspaceDormantDeletingAt(ctx context.Context, arg UpdateWorkspaceDormantDeletingAtParams) (WorkspaceTable, error)
+81 -2
View File
@@ -14938,9 +14938,9 @@ func TestAIGatewayKeysQueries(t *testing.T) {
require.Len(t, keys, 2)
requireAIGatewayKeysRow(t, keys[0], first, firstRow.CreatedAt)
require.False(t, keys[0].LastUsedAt.Valid)
require.False(t, keys[0].LastHeartbeatAt.Valid)
requireAIGatewayKeysRow(t, keys[1], second, secondRow.CreatedAt)
require.False(t, keys[1].LastUsedAt.Valid)
require.False(t, keys[1].LastHeartbeatAt.Valid)
deleted, err := db.DeleteAIGatewayKey(ctx, first.ID)
require.NoError(t, err)
@@ -14958,6 +14958,85 @@ func TestAIGatewayKeysQueries(t *testing.T) {
requireAIGatewayKeysRow(t, keys[0], second, secondRow.CreatedAt)
}
func TestGetAIGatewayKeyByHashedSecret(t *testing.T) {
t.Parallel()
db, _ := dbtestutil.NewDB(t)
ctx := testutil.Context(t, testutil.WaitLong)
first := aiGatewayKeyParams("lookup-first", "key_lookup1")
second := aiGatewayKeyParams("lookup-second", "key_lookup2")
_, err := db.InsertAIGatewayKey(ctx, first)
require.NoError(t, err)
_, err = db.InsertAIGatewayKey(ctx, second)
require.NoError(t, err)
key, err := db.GetAIGatewayKeyByHashedSecret(ctx, first.HashedSecret)
require.NoError(t, err)
require.Equal(t, first.ID, key.ID)
require.Equal(t, first.Name, key.Name)
require.Equal(t, first.SecretPrefix, key.SecretPrefix)
require.Equal(t, first.HashedSecret, key.HashedSecret)
key, err = db.GetAIGatewayKeyByHashedSecret(ctx, second.HashedSecret)
require.NoError(t, err)
require.Equal(t, second.ID, key.ID)
// An unknown secret returns no rows
key, err = db.GetAIGatewayKeyByHashedSecret(ctx, []byte("does-not-exist"))
require.ErrorIs(t, err, sql.ErrNoRows)
require.Empty(t, key.ID)
}
func TestUpdateAIGatewayKeyLastHeartbeatAt(t *testing.T) {
t.Parallel()
db, _, sqlDB := dbtestutil.NewDBWithSQLDB(t)
ctx := testutil.Context(t, testutil.WaitLong)
params := aiGatewayKeyParams("liveness-key", "key_live___")
row, err := db.InsertAIGatewayKey(ctx, params)
require.NoError(t, err)
// last_heartbeat_at starts NULL until a session records liveness.
keys, err := db.ListAIGatewayKeys(ctx)
require.NoError(t, err)
require.Len(t, keys, 1)
require.False(t, keys[0].LastHeartbeatAt.Valid)
rows, err := db.UpdateAIGatewayKeyLastHeartbeatAt(ctx, params.ID)
require.NoError(t, err)
require.EqualValues(t, 1, rows)
keys, err = db.ListAIGatewayKeys(ctx)
require.NoError(t, err)
require.Len(t, keys, 1)
require.True(t, keys[0].LastHeartbeatAt.Valid)
// The database stamps the timestamp, so compare against the row's
// DB-generated CreatedAt to avoid client clock skew.
require.False(t, keys[0].LastHeartbeatAt.Time.Before(row.CreatedAt))
// Updating a key that does not exist is a no-op, not an error.
rows, err = db.UpdateAIGatewayKeyLastHeartbeatAt(ctx, uuid.New())
require.NoError(t, err)
require.EqualValues(t, 0, rows)
// Set last_heartbeat_at to old time to confirm the update overwrites it with a fresh timestamp.
staleTime := row.CreatedAt.Add(-time.Hour)
_, err = sqlDB.ExecContext(ctx, "UPDATE ai_gateway_keys SET last_heartbeat_at = $1 WHERE id = $2", staleTime, params.ID)
require.NoError(t, err)
rows, err = db.UpdateAIGatewayKeyLastHeartbeatAt(ctx, params.ID)
require.NoError(t, err)
require.EqualValues(t, 1, rows)
keys, err = db.ListAIGatewayKeys(ctx)
require.NoError(t, err)
require.Len(t, keys, 1)
require.True(t, keys[0].LastHeartbeatAt.Time.After(staleTime))
}
func aiGatewayKeyParams(name string, secretPrefix string) database.InsertAIGatewayKeyParams {
return database.InsertAIGatewayKeyParams{
ID: uuid.New(),
+117 -20
View File
@@ -113,15 +113,15 @@ func (q *sqlQuerier) ActivityBumpWorkspace(ctx context.Context, arg ActivityBump
const deleteAIGatewayKey = `-- name: DeleteAIGatewayKey :one
DELETE FROM ai_gateway_keys WHERE id = $1
RETURNING id, name, secret_prefix, created_at, last_used_at
RETURNING id, name, secret_prefix, created_at, last_heartbeat_at
`
type DeleteAIGatewayKeyRow struct {
ID uuid.UUID `db:"id" json:"id"`
Name string `db:"name" json:"name"`
SecretPrefix string `db:"secret_prefix" json:"secret_prefix"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
LastUsedAt sql.NullTime `db:"last_used_at" json:"last_used_at"`
ID uuid.UUID `db:"id" json:"id"`
Name string `db:"name" json:"name"`
SecretPrefix string `db:"secret_prefix" json:"secret_prefix"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
LastHeartbeatAt sql.NullTime `db:"last_heartbeat_at" json:"last_heartbeat_at"`
}
func (q *sqlQuerier) DeleteAIGatewayKey(ctx context.Context, id uuid.UUID) (DeleteAIGatewayKeyRow, error) {
@@ -132,7 +132,30 @@ func (q *sqlQuerier) DeleteAIGatewayKey(ctx context.Context, id uuid.UUID) (Dele
&i.Name,
&i.SecretPrefix,
&i.CreatedAt,
&i.LastUsedAt,
&i.LastHeartbeatAt,
)
return i, err
}
const getAIGatewayKeyByHashedSecret = `-- name: GetAIGatewayKeyByHashedSecret :one
SELECT id, created_at, name, secret_prefix, hashed_secret, last_heartbeat_at
FROM ai_gateway_keys
WHERE hashed_secret = $1
`
// Authenticates a standalone AI Gateway replica by its hashed key secret,
// returning the matched key. The lookup is an exact match on a unique index,
// so a returned row is itself proof the secret is valid.
func (q *sqlQuerier) GetAIGatewayKeyByHashedSecret(ctx context.Context, hashedSecret []byte) (AIGatewayKey, error) {
row := q.db.QueryRowContext(ctx, getAIGatewayKeyByHashedSecret, hashedSecret)
var i AIGatewayKey
err := row.Scan(
&i.ID,
&i.CreatedAt,
&i.Name,
&i.SecretPrefix,
&i.HashedSecret,
&i.LastHeartbeatAt,
)
return i, err
}
@@ -175,17 +198,17 @@ func (q *sqlQuerier) InsertAIGatewayKey(ctx context.Context, arg InsertAIGateway
}
const listAIGatewayKeys = `-- name: ListAIGatewayKeys :many
SELECT id, name, secret_prefix, created_at, last_used_at
SELECT id, name, secret_prefix, created_at, last_heartbeat_at
FROM ai_gateway_keys
ORDER BY created_at ASC
`
type ListAIGatewayKeysRow struct {
ID uuid.UUID `db:"id" json:"id"`
Name string `db:"name" json:"name"`
SecretPrefix string `db:"secret_prefix" json:"secret_prefix"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
LastUsedAt sql.NullTime `db:"last_used_at" json:"last_used_at"`
ID uuid.UUID `db:"id" json:"id"`
Name string `db:"name" json:"name"`
SecretPrefix string `db:"secret_prefix" json:"secret_prefix"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
LastHeartbeatAt sql.NullTime `db:"last_heartbeat_at" json:"last_heartbeat_at"`
}
func (q *sqlQuerier) ListAIGatewayKeys(ctx context.Context) ([]ListAIGatewayKeysRow, error) {
@@ -202,7 +225,7 @@ func (q *sqlQuerier) ListAIGatewayKeys(ctx context.Context) ([]ListAIGatewayKeys
&i.Name,
&i.SecretPrefix,
&i.CreatedAt,
&i.LastUsedAt,
&i.LastHeartbeatAt,
); err != nil {
return nil, err
}
@@ -217,6 +240,23 @@ func (q *sqlQuerier) ListAIGatewayKeys(ctx context.Context) ([]ListAIGatewayKeys
return items, nil
}
const updateAIGatewayKeyLastHeartbeatAt = `-- name: UpdateAIGatewayKeyLastHeartbeatAt :execrows
UPDATE ai_gateway_keys
SET last_heartbeat_at = NOW()
WHERE id = $1
`
// Records heartbeat liveness for an active Gateway DRPC session. The database sets the
// timestamp so it stays consistent regardless of clock drift between API
// replicas.
func (q *sqlQuerier) UpdateAIGatewayKeyLastHeartbeatAt(ctx context.Context, id uuid.UUID) (int64, error) {
result, err := q.db.ExecContext(ctx, updateAIGatewayKeyLastHeartbeatAt, id)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
const deleteAIProviderKey = `-- name: DeleteAIProviderKey :exec
DELETE FROM
ai_provider_keys
@@ -36200,6 +36240,31 @@ func (q *sqlQuerier) UpdateWorkspaceBuildFlagsByID(ctx context.Context, arg Upda
return err
}
const updateWorkspaceBuildNotifiedAutostopDeadline = `-- name: UpdateWorkspaceBuildNotifiedAutostopDeadline :exec
UPDATE
workspace_builds
SET
notified_autostop_deadline = $1::timestamptz,
updated_at = $2::timestamptz
WHERE id = $3::uuid
`
type UpdateWorkspaceBuildNotifiedAutostopDeadlineParams struct {
NotifiedAutostopDeadline time.Time `db:"notified_autostop_deadline" json:"notified_autostop_deadline"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
ID uuid.UUID `db:"id" json:"id"`
}
// Stamps the deadline value that an autostop reminder was last sent for. Once
// this equals the build's deadline the reminder is considered handled and the
// lifecycle executor will not send another for this deadline, which makes the
// reminder idempotent and HA-safe. It re-arms automatically when the deadline
// changes (e.g. an activity bump).
func (q *sqlQuerier) UpdateWorkspaceBuildNotifiedAutostopDeadline(ctx context.Context, arg UpdateWorkspaceBuildNotifiedAutostopDeadlineParams) error {
_, err := q.db.ExecContext(ctx, updateWorkspaceBuildNotifiedAutostopDeadline, arg.NotifiedAutostopDeadline, arg.UpdatedAt, arg.ID)
return err
}
const updateWorkspaceBuildProvisionerStateByID = `-- name: UpdateWorkspaceBuildProvisionerStateByID :exec
UPDATE
workspace_builds
@@ -38083,7 +38148,7 @@ func (q *sqlQuerier) GetWorkspacesByTemplateID(ctx context.Context, templateID u
return items, nil
}
const getWorkspacesEligibleForTransition = `-- name: GetWorkspacesEligibleForTransition :many
const getWorkspacesEligibleForLifecycleAction = `-- name: GetWorkspacesEligibleForLifecycleAction :many
SELECT
workspaces.id,
workspaces.name,
@@ -38206,6 +38271,34 @@ WHERE
provisioner_jobs.job_status = 'failed'::provisioner_job_status AND
provisioner_jobs.completed_at IS NOT NULL AND
($1 :: timestamptz) - provisioner_jobs.completed_at > (INTERVAL '1 millisecond' * (templates.failure_ttl / 1000000))
) OR
-- A workspace may be eligible for an autostop reminder if the following are true:
-- * The latest build is a successfully provisioned start build.
-- * The workspace is not dormant and its owner is not suspended.
-- * The build has a deadline in the future (we never remind about a stop already due).
-- * The template opts in (time_til_autostop_notify > 0) and now is within the lead window.
-- * A reminder has not yet been sent for THIS deadline.
--
-- NOTE: time_til_autostop_notify has no upper bound. If it exceeds a
-- workspace's remaining lifetime, the notify window already includes "now"
-- at build creation. This arm intentionally still only matches builds whose
-- deadline is in the future (deadline > now) and whose marker has not yet
-- been stamped (notified_autostop_deadline != deadline), so at most ONE
-- reminder is ever produced for a given deadline regardless of how large the
-- field is. The field is stored in nanoseconds, so convert to an interval
-- the same way the dormancy arm does: nanoseconds / 1000000 yields
-- milliseconds.
(
provisioner_jobs.job_status = 'succeeded'::provisioner_job_status AND
workspace_builds.transition = 'start'::workspace_transition AND
workspaces.dormant_at IS NULL AND
users.status != 'suspended'::user_status AND
workspace_builds.deadline != '0001-01-01 00:00:00+00'::timestamptz AND
workspace_builds.deadline > $1::timestamptz AND
templates.time_til_autostop_notify > 0 AND
workspace_builds.deadline <= ($1::timestamptz) + (INTERVAL '1 millisecond' * (templates.time_til_autostop_notify / 1000000)) AND
workspace_builds.notified_autostop_deadline != workspace_builds.deadline
)
)
AND workspaces.deleted = 'false'
@@ -38215,21 +38308,25 @@ WHERE
AND workspaces.owner_id != 'c42fdf75-3097-471c-8c33-fb52454d81c0'::UUID
`
type GetWorkspacesEligibleForTransitionRow struct {
type GetWorkspacesEligibleForLifecycleActionRow struct {
ID uuid.UUID `db:"id" json:"id"`
Name string `db:"name" json:"name"`
BuildTemplateVersionID uuid.NullUUID `db:"build_template_version_id" json:"build_template_version_id"`
}
func (q *sqlQuerier) GetWorkspacesEligibleForTransition(ctx context.Context, now time.Time) ([]GetWorkspacesEligibleForTransitionRow, error) {
rows, err := q.db.QueryContext(ctx, getWorkspacesEligibleForTransition, now)
// Returns workspaces the lifecycle executor must act on this tick. An
// "action" is a state transition (autostart/autostop/dormancy/delete), a
// dormancy mark (which has no build transition), or a one-time autostop
// reminder notification (which only stamps a marker, no transition).
func (q *sqlQuerier) GetWorkspacesEligibleForLifecycleAction(ctx context.Context, now time.Time) ([]GetWorkspacesEligibleForLifecycleActionRow, error) {
rows, err := q.db.QueryContext(ctx, getWorkspacesEligibleForLifecycleAction, now)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetWorkspacesEligibleForTransitionRow
var items []GetWorkspacesEligibleForLifecycleActionRow
for rows.Next() {
var i GetWorkspacesEligibleForTransitionRow
var i GetWorkspacesEligibleForLifecycleActionRow
if err := rows.Scan(&i.ID, &i.Name, &i.BuildTemplateVersionID); err != nil {
return nil, err
}
+18 -2
View File
@@ -4,10 +4,26 @@ VALUES ($1, @name, $2, $3, NOW())
RETURNING id, name, secret_prefix, created_at;
-- name: ListAIGatewayKeys :many
SELECT id, name, secret_prefix, created_at, last_used_at
SELECT id, name, secret_prefix, created_at, last_heartbeat_at
FROM ai_gateway_keys
ORDER BY created_at ASC;
-- name: DeleteAIGatewayKey :one
DELETE FROM ai_gateway_keys WHERE id = $1
RETURNING id, name, secret_prefix, created_at, last_used_at;
RETURNING id, name, secret_prefix, created_at, last_heartbeat_at;
-- name: GetAIGatewayKeyByHashedSecret :one
-- Authenticates a standalone AI Gateway replica by its hashed key secret,
-- returning the matched key. The lookup is an exact match on a unique index,
-- so a returned row is itself proof the secret is valid.
SELECT *
FROM ai_gateway_keys
WHERE hashed_secret = $1;
-- name: UpdateAIGatewayKeyLastHeartbeatAt :execrows
-- Records heartbeat liveness for an active Gateway DRPC session. The database sets the
-- timestamp so it stays consistent regardless of clock drift between API
-- replicas.
UPDATE ai_gateway_keys
SET last_heartbeat_at = NOW()
WHERE id = $1;
@@ -141,6 +141,19 @@ SET
updated_at = @updated_at::timestamptz
WHERE id = @id::uuid;
-- name: UpdateWorkspaceBuildNotifiedAutostopDeadline :exec
-- Stamps the deadline value that an autostop reminder was last sent for. Once
-- this equals the build's deadline the reminder is considered handled and the
-- lifecycle executor will not send another for this deadline, which makes the
-- reminder idempotent and HA-safe. It re-arms automatically when the deadline
-- changes (e.g. an activity bump).
UPDATE
workspace_builds
SET
notified_autostop_deadline = @notified_autostop_deadline::timestamptz,
updated_at = @updated_at::timestamptz
WHERE id = @id::uuid;
-- name: GetActiveWorkspaceBuildsByTemplateID :many
SELECT wb.*
FROM (
+33 -1
View File
@@ -739,7 +739,11 @@ SELECT
stopped_workspaces.count AS stopped_workspaces
FROM pending_workspaces, building_workspaces, running_workspaces, failed_workspaces, stopped_workspaces;
-- name: GetWorkspacesEligibleForTransition :many
-- name: GetWorkspacesEligibleForLifecycleAction :many
-- Returns workspaces the lifecycle executor must act on this tick. An
-- "action" is a state transition (autostart/autostop/dormancy/delete), a
-- dormancy mark (which has no build transition), or a one-time autostop
-- reminder notification (which only stamps a marker, no transition).
SELECT
workspaces.id,
workspaces.name,
@@ -862,6 +866,34 @@ WHERE
provisioner_jobs.job_status = 'failed'::provisioner_job_status AND
provisioner_jobs.completed_at IS NOT NULL AND
(@now :: timestamptz) - provisioner_jobs.completed_at > (INTERVAL '1 millisecond' * (templates.failure_ttl / 1000000))
) OR
-- A workspace may be eligible for an autostop reminder if the following are true:
-- * The latest build is a successfully provisioned start build.
-- * The workspace is not dormant and its owner is not suspended.
-- * The build has a deadline in the future (we never remind about a stop already due).
-- * The template opts in (time_til_autostop_notify > 0) and now is within the lead window.
-- * A reminder has not yet been sent for THIS deadline.
--
-- NOTE: time_til_autostop_notify has no upper bound. If it exceeds a
-- workspace's remaining lifetime, the notify window already includes "now"
-- at build creation. This arm intentionally still only matches builds whose
-- deadline is in the future (deadline > now) and whose marker has not yet
-- been stamped (notified_autostop_deadline != deadline), so at most ONE
-- reminder is ever produced for a given deadline regardless of how large the
-- field is. The field is stored in nanoseconds, so convert to an interval
-- the same way the dormancy arm does: nanoseconds / 1000000 yields
-- milliseconds.
(
provisioner_jobs.job_status = 'succeeded'::provisioner_job_status AND
workspace_builds.transition = 'start'::workspace_transition AND
workspaces.dormant_at IS NULL AND
users.status != 'suspended'::user_status AND
workspace_builds.deadline != '0001-01-01 00:00:00+00'::timestamptz AND
workspace_builds.deadline > @now::timestamptz AND
templates.time_til_autostop_notify > 0 AND
workspace_builds.deadline <= (@now::timestamptz) + (INTERVAL '1 millisecond' * (templates.time_til_autostop_notify / 1000000)) AND
workspace_builds.notified_autostop_deadline != workspace_builds.deadline
)
)
AND workspaces.deleted = 'false'
+1
View File
@@ -254,6 +254,7 @@ sql:
login_type_oauth2_provider_app: LoginTypeOAuth2ProviderApp
crypto_key_feature_workspace_apps_api_key: CryptoKeyFeatureWorkspaceAppsAPIKey
crypto_key_feature_oidc_convert: CryptoKeyFeatureOIDCConvert
crypto_key_feature_nats_ca: CryptoKeyFeatureNATSCA
stale_interval_ms: StaleIntervalMS
has_ai_task: HasAITask
ai_task_sidebar_app_id: AITaskSidebarAppID
+2 -4
View File
@@ -286,9 +286,7 @@ func TestConcurrencyLimit(t *testing.T) {
var wg sync.WaitGroup
for i := 0; i < maxConcurrency; i++ {
wg.Add(1)
go func() {
defer wg.Done()
wg.Go(func() {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, server.URL+"/", nil)
if err != nil {
results <- result{err: err}
@@ -301,7 +299,7 @@ func TestConcurrencyLimit(t *testing.T) {
}
defer resp.Body.Close()
results <- result{statusCode: resp.StatusCode}
}()
})
}
// Wait for all requests to enter the handler with a timeout.
+4 -8
View File
@@ -445,11 +445,9 @@ func TestSMTP(t *testing.T) {
// Start mock SMTP server in the background.
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
wg.Go(func() {
assert.NoError(t, srv.Serve(listen))
}()
})
// Wait for the server to become pingable.
require.Eventually(t, func() bool {
@@ -590,11 +588,9 @@ func TestSMTPEnvelopeAndHeaders(t *testing.T) {
handler := dispatch.NewSMTPHandler(cfg, logger.Named("smtp"))
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
wg.Go(func() {
assert.NoError(t, srv.Serve(listen))
}()
})
require.Eventually(t, func() bool {
cl, err := smtptest.PingClient(listen, false, false)
+2 -4
View File
@@ -1541,11 +1541,9 @@ func TestNotificationTemplates_Golden(t *testing.T) {
// Start mock SMTP server in the background.
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
wg.Go(func() {
assert.NoError(t, srv.Serve(listen))
}()
})
// Wait for the server to become pingable.
require.Eventually(t, func() bool {
+12 -21
View File
@@ -421,13 +421,10 @@ func TestOAuth2ConcurrentSecurityOperations(t *testing.T) {
// Launch concurrent attempts to access the client configuration
for i := 0; i < numGoroutines; i++ {
wg.Add(1)
go func(index int) {
defer wg.Done()
wg.Go(func() {
_, err := client.GetOAuth2ClientConfiguration(ctx, regResp.ClientID, regResp.RegistrationAccessToken)
errors[index] = err
}(i)
errors[i] = err
})
}
wg.Wait()
@@ -448,23 +445,20 @@ func TestOAuth2ConcurrentSecurityOperations(t *testing.T) {
// Launch concurrent attempts with invalid tokens
for i := 0; i < numGoroutines; i++ {
wg.Add(1)
go func(index int) {
defer wg.Done()
_, err := client.GetOAuth2ClientConfiguration(ctx, regResp.ClientID, fmt.Sprintf("invalid-token-%d", index))
wg.Go(func() {
_, err := client.GetOAuth2ClientConfiguration(ctx, regResp.ClientID, fmt.Sprintf("invalid-token-%d", i))
if err == nil {
t.Errorf("Expected error for goroutine %d", index)
t.Errorf("Expected error for goroutine %d", i)
return
}
var httpErr *codersdk.Error
if !errors.As(err, &httpErr) {
t.Errorf("Expected codersdk.Error for goroutine %d", index)
t.Errorf("Expected codersdk.Error for goroutine %d", i)
return
}
statusCodes[index] = httpErr.StatusCode()
}(i)
statusCodes[i] = httpErr.StatusCode()
})
}
wg.Wait()
@@ -494,13 +488,10 @@ func TestOAuth2ConcurrentSecurityOperations(t *testing.T) {
// Launch concurrent deletion attempts
for i := 0; i < numGoroutines; i++ {
wg.Add(1)
go func(index int) {
defer wg.Done()
wg.Go(func() {
err := client.DeleteOAuth2ClientConfiguration(ctx, deleteRegResp.ClientID, deleteRegResp.RegistrationAccessToken)
deleteResults[index] = err
}(i)
deleteResults[i] = err
})
}
wg.Wait()
+1
View File
@@ -20,6 +20,7 @@ var (
// - "ActionCreate" :: create an AI Gateway key
// - "ActionDelete" :: delete an AI Gateway key
// - "ActionRead" :: read AI Gateway keys
// - "ActionUpdate" :: update an AI Gateway key
ResourceAIGatewayKey = Object{
Type: "ai_gateway_key",
}
+1
View File
@@ -434,6 +434,7 @@ var RBACPermissions = map[string]PermissionDefinition{
Actions: map[Action]ActionDefinition{
ActionCreate: "create an AI Gateway key",
ActionRead: "read AI Gateway keys",
ActionUpdate: "update an AI Gateway key",
ActionDelete: "delete an AI Gateway key",
},
},
+5 -1
View File
@@ -409,12 +409,16 @@ func ReloadBuiltinRoles(opts *RoleOptions) {
// Workspace is specifically handled based on the opts.NoOwnerWorkspaceExec.
// Owners can inspect and delete personal skills for operability and
// abuse handling, but cannot create or edit user-authored instructions.
allPermsExcept(ResourceWorkspaceDormant, ResourcePrebuiltWorkspace, ResourceWorkspace, ResourceUserSecret, ResourceUserSkill, ResourceUsageEvent, ResourceBoundaryUsage, ResourceBoundaryLog, ResourceAiSeat),
allPermsExcept(ResourceWorkspaceDormant, ResourcePrebuiltWorkspace, ResourceWorkspace, ResourceUserSecret, ResourceUserSkill, ResourceUsageEvent, ResourceBoundaryUsage, ResourceBoundaryLog, ResourceAiSeat, ResourceAIGatewayKey),
// This adds back in the Workspace permissions.
Permissions(map[string][]policy.Action{
ResourceWorkspace.Type: ownerWorkspaceActions,
ResourceWorkspaceDormant.Type: {policy.ActionRead, policy.ActionDelete, policy.ActionCreate, policy.ActionUpdate, policy.ActionWorkspaceStop, policy.ActionCreateAgent, policy.ActionDeleteAgent, policy.ActionUpdateAgent},
ResourceUserSkill.Type: {policy.ActionRead, policy.ActionDelete},
// Owners manage AI Gateway keys but cannot update them. The
// update action records last-used liveness and is reserved
// for the system actor authenticating Gateway replicas.
ResourceAIGatewayKey.Type: {policy.ActionCreate, policy.ActionRead, policy.ActionDelete},
// PrebuiltWorkspaces are a subset of Workspaces.
// Explicitly setting PrebuiltWorkspace permissions for clarity.
// Note: even without PrebuiltWorkspace permissions, access is still granted via Workspace permissions.
+19
View File
@@ -1311,6 +1311,25 @@ func TestRolePermissions(t *testing.T) {
},
},
},
{
// Updating an AI Gateway key records last-used liveness when a
// Gateway replica authenticates. It is reserved for the system
// actor, so no user-facing role, including owner, is authorized.
Name: "AIGatewayKeyUpdate",
Actions: []policy.Action{policy.ActionUpdate},
Resource: rbac.ResourceAIGatewayKey,
AuthorizeMap: map[bool][]hasAuthSubjects{
true: {},
false: {
owner,
orgWorkspaceAccessUser, memberMe, agentsAccessUser,
orgAdmin, otherOrgAdmin,
orgAuditor, otherOrgAuditor,
templateAdmin, orgTemplateAdmin, otherOrgTemplateAdmin,
userAdmin, orgUserAdmin, otherOrgUserAdmin,
},
},
},
{
Name: "BoundaryUsage",
Actions: []policy.Action{policy.ActionRead, policy.ActionUpdate, policy.ActionDelete},
+3
View File
@@ -10,6 +10,7 @@ const (
ScopeAiGatewayKeyCreate ScopeName = "ai_gateway_key:create"
ScopeAiGatewayKeyDelete ScopeName = "ai_gateway_key:delete"
ScopeAiGatewayKeyRead ScopeName = "ai_gateway_key:read"
ScopeAiGatewayKeyUpdate ScopeName = "ai_gateway_key:update"
ScopeAiModelPriceRead ScopeName = "ai_model_price:read"
ScopeAiModelPriceUpdate ScopeName = "ai_model_price:update"
ScopeAiProviderCreate ScopeName = "ai_provider:create"
@@ -193,6 +194,7 @@ func (e ScopeName) Valid() bool {
ScopeAiGatewayKeyCreate,
ScopeAiGatewayKeyDelete,
ScopeAiGatewayKeyRead,
ScopeAiGatewayKeyUpdate,
ScopeAiModelPriceRead,
ScopeAiModelPriceUpdate,
ScopeAiProviderCreate,
@@ -377,6 +379,7 @@ func AllScopeNameValues() []ScopeName {
ScopeAiGatewayKeyCreate,
ScopeAiGatewayKeyDelete,
ScopeAiGatewayKeyRead,
ScopeAiGatewayKeyUpdate,
ScopeAiModelPriceRead,
ScopeAiModelPriceUpdate,
ScopeAiProviderCreate,
@@ -53,7 +53,7 @@
{
"name": "api_key",
"type": "string",
"description": "API key for the selected AI provider. This will be set as the appropriate environment variable based on the provider.",
"description": "API key for the selected AI provider.",
"default": "",
"required": false,
"sensitive": true,
@@ -62,7 +62,7 @@
{
"name": "base_aider_config",
"type": "string",
"description": "Base Aider configuration in yaml format. Will be stored in .aider.conf.yml file.\n \noptions include:\nread:\n - CONVENTIONS.md\n - anotherfile.txt\n - thirdfile.py\nmodel: xxx\n##Specify the OpenAI API key\nopenai-api-key: xxx\n## (deprecated, use --set-env OPENAI_API_TYPE=\u003cvalue\u003e)\nopenai-api-type: xxx\n## (deprecated, use --set-env OPENAI_API_VERSION=\u003cvalue\u003e)\nopenai-api-version: xxx\n## (deprecated, use --set-env OPENAI_API_DEPLOYMENT_ID=\u003cvalue\u003e)\nopenai-api-deployment-id: xxx\n## Set an environment variable (to control API settings, can be used multiple times)\nset-env: xxx\n## Specify multiple values like this:\nset-env:\n - xxx\n - yyy\n - zzz\n\nReference : https://aider.chat/docs/config/aider_conf.html\n",
"description": "Base Aider configuration in YAML format. Stored in .aider.conf.yml.",
"required": false,
"sensitive": false,
"computed": false
@@ -132,7 +132,7 @@
{
"name": "model",
"type": "string",
"description": "AI model to use with Aider. Can use Aider's built-in aliases like '4o' (gpt-4o), 'sonnet' (claude-3-7-sonnet), 'opus' (claude-3-opus), etc.",
"description": "AI model to use with Aider.",
"required": true,
"sensitive": false,
"computed": false
@@ -36,7 +36,7 @@
{
"name": "agentapi_chat_based_path",
"type": "bool",
"description": "Whether to use chat-based path for AgentAPI.Required if CODER_WILDCARD_ACCESS_URL is not defined in coder deployment",
"description": "Use chat-based path for AgentAPI.",
"default": false,
"required": false,
"sensitive": false,
@@ -99,7 +99,7 @@
{
"name": "coder_mcp_instructions",
"type": "string",
"description": "Instructions for the Coder MCP server integration. This defines how the agent should report tasks to Coder.",
"description": "Instructions for the Coder MCP server integration.",
"default": "YOU MUST REPORT ALL TASKS TO CODER.\nWhen reporting tasks you MUST follow these EXACT instructions:\n- IMMEDIATELY report status after receiving ANY user message\n- Be granular If you are investigating with multiple steps report each step to coder.\n\nTask state MUST be one of the following:\n- Use \"state\": \"working\" when actively processing WITHOUT needing additional user input\n- Use \"state\": \"complete\" only when finished with a task\n- Use \"state\": \"failure\" when you need ANY user input lack sufficient details or encounter blockers.\n\nTask summaries MUST:\n- Include specifics about what you're doing\n- Include clear and actionable steps for the user\n- Be less than 160 characters in length\n",
"required": false,
"sensitive": false,
@@ -37,7 +37,7 @@
{
"name": "claude_binary_path",
"type": "string",
"description": "Directory where the Claude Code binary is located. Use this if Claude is pre-installed or installed outside the module to a non-default location.",
"description": "Directory where the Claude Code binary is located.",
"default": "$HOME/.local/bin",
"required": false,
"sensitive": false,
@@ -46,7 +46,7 @@
{
"name": "claude_code_oauth_token",
"type": "string",
"description": "OAuth token passed to Claude Code via the CLAUDE_CODE_OAUTH_TOKEN env var. Generate one with `claude setup-token`.",
"description": "OAuth token for Claude Code.",
"default": "",
"required": false,
"sensitive": true,
@@ -100,7 +100,7 @@
{
"name": "mcp",
"type": "string",
"description": "JSON-encoded string of MCP server configurations. When set, servers are added at Claude Code's user scope so they are available across every project the workspace owner opens.",
"description": "JSON-encoded MCP server configurations for Claude Code.",
"default": "",
"required": false,
"sensitive": false,
@@ -109,7 +109,7 @@
{
"name": "model",
"type": "string",
"description": "Sets the default model for Claude Code via ANTHROPIC_MODEL env var. If empty, Claude Code uses its default. Supports aliases (sonnet, opus) or full model names.",
"description": "Default model for Claude Code via ANTHROPIC_MODEL env var.",
"default": "",
"required": false,
"sensitive": false,
@@ -126,7 +126,7 @@
{
"name": "pre_install_script",
"type": "string",
"description": "Custom script to run before installing Claude Code. Can be used for dependency ordering between modules (e.g., waiting for git-clone to complete before Claude Code initialization).",
"description": "Custom script to run before installing Claude Code.",
"required": false,
"sensitive": false,
"computed": false
@@ -134,7 +134,7 @@
{
"name": "workdir",
"type": "string",
"description": "Optional project directory. When set, the module pre-creates it if missing and pre-accepts the Claude Code trust/onboarding prompt for it in ~/.claude.json.",
"description": "Project directory. Pre-created if missing.",
"required": false,
"sensitive": false,
"computed": false
@@ -82,7 +82,7 @@
{
"name": "open_in",
"type": "string",
"description": "Determines where the app will be opened. Valid values are `\"tab\"` and `\"slim-window\" (default)`.\n`\"tab\"` opens in a new tab in the same browser window.\n`\"slim-window\"` opens a new browser window without navigation controls.\n",
"description": "Where to open the app: \"tab\" or \"slim-window\" (default).",
"default": "slim-window",
"required": false,
"sensitive": false,
@@ -44,7 +44,7 @@
{
"name": "open_recent",
"type": "bool",
"description": "Open the most recent workspace or folder. Falls back to the folder if there is no recent workspace or folder to open.",
"description": "Open the most recent workspace or folder.",
"default": false,
"required": false,
"sensitive": false,
@@ -43,7 +43,7 @@
{
"name": "description",
"type": "string",
"description": "A custom description for the dotfiles parameter. This is shown in the UI - and allows you to customize the instructions you give to your users.",
"description": "Custom description for the dotfiles parameter shown in the UI.",
"default": "Enter a URL for a [dotfiles repository](https://dotfiles.github.io) to personalize your workspace. Use an SSH URL (e.g. `git@host:user/repo`) if your Git provider restricts HTTPS cloning.",
"required": false,
"sensitive": false,
@@ -52,7 +52,7 @@
{
"name": "dotfiles_branch",
"type": "string",
"description": "The branch to use for the dotfiles repository (optional, when set, the user isn't prompted for the branch)",
"description": "Branch to use for the dotfiles repository.",
"required": false,
"sensitive": false,
"computed": false
@@ -77,7 +77,7 @@
{
"name": "post_clone_script",
"type": "string",
"description": "Custom script to run after applying dotfiles. Runs every time, even if dotfiles were already applied.",
"description": "Custom script to run after applying dotfiles.",
"required": false,
"sensitive": false,
"computed": false
@@ -25,7 +25,7 @@
{
"name": "agent_name",
"type": "string",
"description": "The name of the coder_agent resource. Required when `subdomain` is `false` so the path-based base URL matches the URL Coder serves.",
"description": "The name of the coder_agent resource.",
"required": false,
"sensitive": false,
"computed": false
@@ -52,7 +52,7 @@
{
"name": "post_clone_script",
"type": "string",
"description": "Custom script to run after cloning the repository. Runs always after git clone, even if the repository already exists.",
"description": "Custom script to run after cloning the repository.",
"required": false,
"sensitive": false,
"computed": false
@@ -60,7 +60,7 @@
{
"name": "pre_clone_script",
"type": "string",
"description": "Custom script to run before cloning the repository. Runs before git clone, even if the repository already exists.",
"description": "Custom script to run before cloning the repository.",
"required": false,
"sensitive": false,
"computed": false
@@ -26,7 +26,7 @@
{
"name": "config",
"type": "string",
"description": "A JSON string of JupyterLab server configuration settings. When set, writes ~/.jupyter/jupyter_server_config.json.",
"description": "JupyterLab server configuration as a JSON string.",
"default": "{}",
"required": false,
"sensitive": false,
@@ -45,7 +45,7 @@
{
"name": "open_recent",
"type": "bool",
"description": "Open the most recent workspace or folder. Falls back to the folder if there is no recent workspace or folder to open.",
"description": "Open the most recent workspace or folder.",
"default": false,
"required": false,
"sensitive": false,
@@ -34,7 +34,7 @@
{
"name": "open_recent",
"type": "bool",
"description": "Open the most recent workspace or folder. Falls back to the folder if there is no recent workspace or folder to open.",
"description": "Open the most recent workspace or folder.",
"default": false,
"required": false,
"sensitive": false,
@@ -46,7 +46,7 @@
{
"name": "commit_id",
"type": "string",
"description": "Specify the commit ID of the VS Code Web binary to pin to a specific version. If left empty, the latest stable version is used.",
"description": "Commit ID to pin VS Code Web to a specific version.",
"default": "",
"required": false,
"sensitive": false,
@@ -35,7 +35,7 @@
{
"name": "mcp",
"type": "string",
"description": "JSON-encoded string to configure MCP servers for Windsurf. When set, writes ~/.codeium/windsurf/mcp_config.json.",
"description": "JSON-encoded MCP server configurations for Windsurf.",
"default": "",
"required": false,
"sensitive": false,
@@ -44,7 +44,7 @@
{
"name": "open_recent",
"type": "bool",
"description": "Open the most recent workspace or folder. Falls back to the folder if there is no recent workspace or folder to open.",
"description": "Open the most recent workspace or folder.",
"default": false,
"required": false,
"sensitive": false,
+23 -12
View File
@@ -31,6 +31,7 @@ import (
"github.com/coder/coder/v2/coderd/dynamicparameters"
"github.com/coder/coder/v2/coderd/externalauth"
"github.com/coder/coder/v2/coderd/httpapi"
"github.com/coder/coder/v2/coderd/httpapi/httperror"
"github.com/coder/coder/v2/coderd/httpmw"
"github.com/coder/coder/v2/coderd/provisionerdserver"
"github.com/coder/coder/v2/coderd/rbac"
@@ -337,14 +338,28 @@ func (api *API) templateVersionExternalAuth(rw http.ResponseWriter, r *http.Requ
templateVersion = httpmw.TemplateVersionParam(r)
)
providers, err := api.templateVersionExternalAuthForUser(ctx, templateVersion, apiKey.UserID)
if err != nil {
httperror.WriteResponseError(ctx, rw, err)
return
}
httpapi.Write(ctx, rw, http.StatusOK, providers)
}
// templateVersionExternalAuthForUser returns the external auth providers
// referenced by the template version, with Authenticated reporting whether
// the given user has a usable token for each provider. Failures are returned
// as httperror response errors suitable for writing directly to an API
// response.
func (api *API) templateVersionExternalAuthForUser(ctx context.Context, templateVersion database.TemplateVersion, userID uuid.UUID) ([]codersdk.TemplateVersionExternalAuth, error) {
var rawProviders []database.ExternalAuthProvider
err := json.Unmarshal(templateVersion.ExternalAuthProviders, &rawProviders)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
return nil, httperror.NewResponseError(http.StatusInternalServerError, codersdk.Response{
Message: "Internal error reading auth config from database",
Detail: err.Error(),
})
return
}
providers := make([]codersdk.TemplateVersionExternalAuth, 0)
@@ -357,21 +372,19 @@ func (api *API) templateVersionExternalAuth(rw http.ResponseWriter, r *http.Requ
}
}
if config == nil {
httpapi.Write(ctx, rw, http.StatusNotFound, codersdk.Response{
return nil, httperror.NewResponseError(http.StatusNotFound, codersdk.Response{
Message: fmt.Sprintf("The template version references a Git auth provider %q that no longer exists.", rawProvider.ID),
Detail: "You'll need to update the template version to use a different provider.",
})
return
}
// This is the URL that will redirect the user with a state token.
redirectURL, err := api.AccessURL.Parse(fmt.Sprintf("/external-auth/%s", config.ID))
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
return nil, httperror.NewResponseError(http.StatusInternalServerError, codersdk.Response{
Message: "Failed to parse access URL.",
Detail: err.Error(),
})
return
}
provider := codersdk.TemplateVersionExternalAuth{
@@ -385,7 +398,7 @@ func (api *API) templateVersionExternalAuth(rw http.ResponseWriter, r *http.Requ
authLink, err := api.Database.GetExternalAuthLink(ctx, database.GetExternalAuthLinkParams{
ProviderID: config.ID,
UserID: apiKey.UserID,
UserID: userID,
})
// If there isn't an auth link, then the user just isn't authenticated.
if errors.Is(err, sql.ErrNoRows) {
@@ -393,27 +406,25 @@ func (api *API) templateVersionExternalAuth(rw http.ResponseWriter, r *http.Requ
continue
}
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
return nil, httperror.NewResponseError(http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching external auth link.",
Detail: err.Error(),
})
return
}
_, err = config.RefreshToken(ctx, api.Database, authLink)
if err != nil && !externalauth.IsInvalidTokenError(err) {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
return nil, httperror.NewResponseError(http.StatusInternalServerError, codersdk.Response{
Message: "Failed to refresh external auth token.",
Detail: err.Error(),
})
return
}
provider.Authenticated = err == nil
providers = append(providers, provider)
}
httpapi.Write(ctx, rw, http.StatusOK, providers)
return providers, nil
}
// @Summary Get template variables by template version
+34 -6
View File
@@ -1037,7 +1037,7 @@ func (api *API) userOAuth2Github(rw http.ResponseWriter, r *http.Request) {
})
return
}
user, link, err := findLinkedUser(ctx, api.Database, githubLinkedID(ghUser), database.LoginTypeGithub, verifiedEmail.GetEmail())
user, link, err := findLinkedUser(ctx, api.Database, githubLinkedID(ghUser), database.LoginTypeGithub, false, verifiedEmail.GetEmail())
if errors.Is(err, errLinkedIDAlreadyBound) {
logger.Warn(ctx, "oauth2: blocked login, account already linked to different identity",
slog.F("email", verifiedEmail.GetEmail()),
@@ -1187,6 +1187,12 @@ type OIDCConfig struct {
// SignupsDisabledText is the text do display on the static error page.
SignupsDisabledText string
PKCEMethods []promoauth.Oauth2PKCEChallengeMethod
// EmailFallback, when true, allows OIDC logins to fall back to
// email-based user matching when the linked_id (issuer+subject) does
// not match an existing user link. INSECURE: weakens the linked_id
// check. Used for IdP brokers that do not issue a stable `sub` for the
// same user across connections.
EmailFallback bool
}
// PKCESupported is to prevent nil pointer dereference.
@@ -1458,7 +1464,7 @@ func (api *API) userOIDC(rw http.ResponseWriter, r *http.Request) {
}
ctx = slog.With(ctx, slog.F("email", email), slog.F("username", username), slog.F("name", name))
user, link, err := findLinkedUser(ctx, api.Database, oidcLinkedID(idToken), database.LoginTypeOIDC, email)
user, link, err := findLinkedUser(ctx, api.Database, oidcLinkedID(idToken), database.LoginTypeOIDC, api.OIDCConfig.EmailFallback, email)
if errors.Is(err, errLinkedIDAlreadyBound) {
logger.Warn(ctx, "oauth2: blocked login, account already linked to different identity",
slog.F("email", email),
@@ -1526,6 +1532,7 @@ func (api *API) userOIDC(rw http.ResponseWriter, r *http.Request) {
UserInfoClaims: supplementaryClaims,
MergedClaims: mergedClaims,
},
AllowInsecureLinkedIDMismatch: api.OIDCConfig.EmailFallback,
}).SetInitAuditRequest(func(params *audit.RequestParams) (*audit.Request[database.User], func()) {
return audit.InitRequest[database.User](rw, params)
})
@@ -1679,6 +1686,13 @@ type oauthLoginParams struct {
// It is used to save the user's claims on login.
UserClaims database.UserLinkClaims
// AllowInsecureLinkedIDMismatch, when true, allows the login to proceed
// when the existing user_link's linked_id differs from LinkedID. The
// existing linked_id is preserved (no overwrite). INSECURE: opt-in
// escape hatch for IdP brokers that emit different subjects for the
// same user across connections.
AllowInsecureLinkedIDMismatch bool
commitLock sync.Mutex
initAuditRequest func(params *audit.RequestParams) *audit.Request[database.User]
commits []func()
@@ -1929,7 +1943,10 @@ func (api *API) oauthLogin(r *http.Request, params *oauthLoginParams) ([]*http.C
// Defense-in-depth: if a concurrent transaction backfilled
// linked_id between findLinkedUser and this point, reject the
// login with a 403 instead of letting it bubble up as a 500.
if link.LinkedID != "" && link.LinkedID != params.LinkedID {
// The INSECURE AllowInsecureLinkedIDMismatch escape hatch
// preserves the existing linked_id and lets the login proceed;
// the warning was already emitted by the caller.
if link.LinkedID != "" && link.LinkedID != params.LinkedID && !params.AllowInsecureLinkedIDMismatch {
return &idpsync.HTTPError{
Code: http.StatusForbidden,
Msg: "Account already linked",
@@ -2180,7 +2197,16 @@ var errLinkedIDAlreadyBound = xerrors.New("user account is already linked to a d
// legacy links (empty linked_id) only. If the user found by email
// already has a link with a different linked_id, errLinkedIDAlreadyBound
// is returned to prevent account takeover via IdP email reuse.
func findLinkedUser(ctx context.Context, db database.Store, linkedID string, loginType database.LoginType, emails ...string) (database.User, database.UserLink, error) {
//
// When allowInsecureLinkedIDMismatch is true, the linked_id mismatch
// check is skipped and the email fallback resolves the login even when
// the existing link's linked_id differs from the current login's. The
// existing linked_id is left intact (no overwrite). This is an INSECURE
// opt-in for IdP brokers that do not issue a stable `sub` for the same
// user across connections.
//
//nolint:revive // allowInsecureLinkedIDMismatch is intentionally a control flag; it gates an INSECURE opt-in.
func findLinkedUser(ctx context.Context, db database.Store, linkedID string, loginType database.LoginType, allowInsecureLinkedIDMismatch bool, emails ...string) (database.User, database.UserLink, error) {
var (
user database.User
link database.UserLink
@@ -2233,8 +2259,10 @@ func findLinkedUser(ctx context.Context, db database.Store, linkedID string, log
// Block email fallback when an existing link has a different linked_id.
// Prevents account takeover via IdP email reuse; first-time and legacy
// (empty linked_id) links pass through.
if err == nil && link.LinkedID != "" && link.LinkedID != linkedID {
// (empty linked_id) links pass through. The INSECURE
// allowInsecureLinkedIDMismatch escape hatch keeps the existing link
// (and its original linked_id) and lets the login proceed.
if err == nil && link.LinkedID != "" && link.LinkedID != linkedID && !allowInsecureLinkedIDMismatch {
return database.User{}, database.UserLink{}, errLinkedIDAlreadyBound
}
+170
View File
@@ -2001,6 +2001,176 @@ func TestUserOIDC(t *testing.T) {
"linked_id must not be modified when the login is blocked")
})
// Tests the INSECURE OIDC email fallback escape hatch. When the
// deployment flag is set, an OIDC login whose subject differs from an
// existing user_link's linked_id but whose email matches must be
// allowed through. The original linked_id is preserved (no overwrite),
// so the user can keep logging in with either subject.
t.Run("OIDCInsecureEmailFallbackAllowed", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
fake := oidctest.NewFakeIDP(t,
oidctest.WithRefresh(func(_ string) error {
return xerrors.New("refreshing token should never occur")
}),
oidctest.WithServing(),
)
cfg := fake.OIDCConfig(t, nil, func(cfg *coderd.OIDCConfig) {
cfg.AllowSignups = true
cfg.EmailFallback = true
})
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug)
owner, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{
OIDCConfig: cfg,
Logger: &logger,
})
// Seed a user whose link records the IdP's first connection.
user := dbgen.User(t, db, database.User{
LoginType: database.LoginTypeOIDC,
})
originalLinkedID := fake.IssuerURL().String() + "||" + "first-connection-sub"
dbgen.UserLink(t, db, database.UserLink{
UserID: user.ID,
LoginType: database.LoginTypeOIDC,
LinkedID: originalLinkedID,
})
// Login with a different subject (the broker emitted a new `sub`
// for the same user) but the same email. With EmailFallback
// enabled the email match resolves the login despite the linked_id
// mismatch.
client, resp := fake.AttemptLogin(t, owner, jwt.MapClaims{
"email": user.Email,
"sub": "second-connection-sub",
})
require.Equal(t, http.StatusOK, resp.StatusCode,
"insecure email fallback must allow login with a mismatched subject")
me, err := client.User(ctx, "me")
require.NoError(t, err)
require.Equal(t, user.ID, me.ID,
"should authenticate as the existing user")
// The original linked_id must be preserved, not overwritten by the
// new subject. This keeps the next login from the original subject
// (which hits the primary linked_id path) working.
link, err := db.GetUserLinkByUserIDLoginType(dbauthz.AsSystemRestricted(context.Background()), database.GetUserLinkByUserIDLoginTypeParams{
UserID: user.ID,
LoginType: database.LoginTypeOIDC,
})
require.NoError(t, err)
require.Equal(t, originalLinkedID, link.LinkedID,
"linked_id must be preserved on insecure email fallback")
})
// Tests that with the INSECURE OIDC email fallback enabled, the
// original subject's login still resolves via the primary linked_id
// path after a fallback login from a different subject. The fallback
// path does not overwrite the link, so the original subject keeps
// matching directly.
t.Run("OIDCInsecureEmailFallbackPreservesOriginalLogin", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
fake := oidctest.NewFakeIDP(t,
oidctest.WithRefresh(func(_ string) error {
return xerrors.New("refreshing token should never occur")
}),
oidctest.WithServing(),
)
cfg := fake.OIDCConfig(t, nil, func(cfg *coderd.OIDCConfig) {
cfg.AllowSignups = true
cfg.EmailFallback = true
})
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug)
owner, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{
OIDCConfig: cfg,
Logger: &logger,
})
user := dbgen.User(t, db, database.User{
LoginType: database.LoginTypeOIDC,
})
const originalSub = "first-connection-sub"
originalLinkedID := fake.IssuerURL().String() + "||" + originalSub
dbgen.UserLink(t, db, database.UserLink{
UserID: user.ID,
LoginType: database.LoginTypeOIDC,
LinkedID: originalLinkedID,
})
// Fallback login with a different subject.
_, resp := fake.AttemptLogin(t, owner, jwt.MapClaims{
"email": user.Email,
"sub": "second-connection-sub",
})
require.Equal(t, http.StatusOK, resp.StatusCode)
// Subsequent login with the original subject must hit the primary
// linked_id match and succeed.
client, resp := fake.AttemptLogin(t, owner, jwt.MapClaims{
"email": user.Email,
"sub": originalSub,
})
require.Equal(t, http.StatusOK, resp.StatusCode)
me, err := client.User(ctx, "me")
require.NoError(t, err)
require.Equal(t, user.ID, me.ID)
link, err := db.GetUserLinkByUserIDLoginType(dbauthz.AsSystemRestricted(context.Background()), database.GetUserLinkByUserIDLoginTypeParams{
UserID: user.ID,
LoginType: database.LoginTypeOIDC,
})
require.NoError(t, err)
require.Equal(t, originalLinkedID, link.LinkedID,
"linked_id must stay anchored to the original subject")
})
// Tests that the INSECURE OIDC email fallback does NOT extend to
// signups: an attacker logging in with a brand-new email (no existing
// user) still goes through the normal signup gate. The escape hatch is
// only about resolving subject-mismatch on existing accounts.
t.Run("OIDCInsecureEmailFallbackDoesNotCreateUsers", func(t *testing.T) {
t.Parallel()
fake := oidctest.NewFakeIDP(t,
oidctest.WithRefresh(func(_ string) error {
return xerrors.New("refreshing token should never occur")
}),
oidctest.WithServing(),
)
cfg := fake.OIDCConfig(t, nil, func(cfg *coderd.OIDCConfig) {
cfg.AllowSignups = false
cfg.EmailFallback = true
})
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug)
owner, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{
OIDCConfig: cfg,
Logger: &logger,
})
// Seed an existing user so the deployment's user count is > 0;
// otherwise the first signup is always allowed.
dbgen.User(t, db, database.User{
LoginType: database.LoginTypeOIDC,
})
// New email, no existing user. Signups are disabled, so the
// fallback flag must not let the login through.
_, resp := fake.AttemptLogin(t, owner, jwt.MapClaims{
"email": "stranger@example.com",
"sub": "stranger-subject",
})
require.Equal(t, http.StatusForbidden, resp.StatusCode,
"insecure email fallback must not bypass the signup gate")
})
t.Run("OIDCSuspended", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
+5 -2
View File
@@ -1341,7 +1341,10 @@ func TestWorkspaceDeleteSuspendedUser(t *testing.T) {
template := coderdtest.CreateTemplate(t, client, first.OrganizationID, version.ID)
workspace := coderdtest.CreateWorkspace(t, client, template.ID)
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID)
require.Equal(t, 1, validateCalls) // Ensure the external link is working
// Ensure the external link is working. Workspace creation validates the
// owner's required external auth, and the build's token injection
// validates it again.
require.Equal(t, 2, validateCalls)
// Suspend the user
ctx := testutil.Context(t, testutil.WaitLong)
@@ -1355,7 +1358,7 @@ func TestWorkspaceDeleteSuspendedUser(t *testing.T) {
})
require.NoError(t, err)
build = coderdtest.AwaitWorkspaceBuildJobCompleted(t, owner, build.ID)
require.Equal(t, 2, validateCalls)
require.Equal(t, 3, validateCalls)
require.Equal(t, codersdk.WorkspaceStatusDeleted, build.Status)
}
+66
View File
@@ -9,6 +9,7 @@ import (
"net/http"
"slices"
"strconv"
"strings"
"time"
"github.com/dustin/go-humanize"
@@ -595,6 +596,27 @@ func createWorkspace(
})
}
// Required external auth is otherwise only enforced by client-side preflight
// checks in the CLI and UI, so API-created workspaces must be validated here
// before any workspace row is inserted or prebuilt workspace is claimed.
templateVersionID := req.TemplateVersionID
if templateVersionID == uuid.Nil {
templateVersionID = template.ActiveVersionID
}
templateVersion, err := api.Database.GetTemplateVersionByID(ctx, templateVersionID)
if err != nil {
if httpapi.Is404Error(err) {
return codersdk.Workspace{}, httperror.ErrResourceNotFound
}
return codersdk.Workspace{}, httperror.NewResponseError(http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching template version.",
Detail: err.Error(),
})
}
if err := api.requireWorkspaceOwnerExternalAuth(ctx, templateVersion, owner.ID); err != nil {
return codersdk.Workspace{}, err
}
dbAutostartSchedule, err := validWorkspaceSchedule(req.AutostartSchedule)
if err != nil {
return codersdk.Workspace{}, httperror.NewResponseError(http.StatusBadRequest, codersdk.Response{
@@ -892,6 +914,50 @@ func createWorkspace(
return w, nil
}
// requireWorkspaceOwnerExternalAuth returns a 403 response error when the
// workspace owner has not authenticated with every required (non-optional)
// external auth provider referenced by the template version. Token injection
// at build time uses the owner's external auth links, so the owner is the
// subject of the check even when another user initiates the build.
func (api *API) requireWorkspaceOwnerExternalAuth(ctx context.Context, templateVersion database.TemplateVersion, ownerID uuid.UUID) error {
//nolint:gocritic // System access is required to validate the workspace owner's external auth links because admins and API clients may create workspaces for other users.
providers, err := api.templateVersionExternalAuthForUser(dbauthz.AsSystemRestricted(ctx), templateVersion, ownerID)
if err != nil {
return err
}
var (
missingNames []string
validations []codersdk.ValidationError
)
for _, provider := range providers {
if provider.Optional || provider.Authenticated {
continue
}
name := provider.DisplayName
if name == "" {
name = provider.ID
}
missingNames = append(missingNames, name)
validations = append(validations, codersdk.ValidationError{
Field: "external_auth",
Detail: provider.ID,
})
}
if len(missingNames) == 0 {
return nil
}
return httperror.NewResponseError(http.StatusForbidden, codersdk.Response{
Message: "External authentication is required to create a workspace with this template.",
Detail: fmt.Sprintf(
"The workspace owner must authenticate with the following external auth providers: %s.",
strings.Join(missingNames, ", "),
),
Validations: validations,
})
}
func requestTemplate(ctx context.Context, req codersdk.CreateWorkspaceRequest, db database.Store) (database.Template, error) {
// If we were given a `TemplateVersionID`, we need to determine the `TemplateID` from it.
templateID := req.TemplateID
+241
View File
@@ -8,6 +8,8 @@ import (
"fmt"
"math"
"net/http"
"net/http/httptest"
"regexp"
"slices"
"strings"
"testing"
@@ -31,6 +33,7 @@ import (
"github.com/coder/coder/v2/coderd/database/dbgen"
"github.com/coder/coder/v2/coderd/database/dbtestutil"
"github.com/coder/coder/v2/coderd/database/dbtime"
"github.com/coder/coder/v2/coderd/externalauth"
"github.com/coder/coder/v2/coderd/notifications"
"github.com/coder/coder/v2/coderd/notifications/notificationstest"
"github.com/coder/coder/v2/coderd/provisionerdserver"
@@ -1466,6 +1469,244 @@ func TestPostWorkspacesByOrganization(t *testing.T) {
})
}
func TestCreateWorkspaceExternalAuth(t *testing.T) {
t.Parallel()
// The expected 403 message returned by createWorkspace when the workspace
// owner is missing required external auth.
const externalAuthRequiredMessage = "External authentication is required to create a workspace with this template."
// externalAuthVersion returns echo responses for a template version whose
// graph references the given external auth providers.
externalAuthVersion := func(providers ...*proto.ExternalAuthProviderResource) *echo.Responses {
return &echo.Responses{
Parse: echo.ParseComplete,
ProvisionGraph: []*proto.Response{{
Type: &proto.Response_Graph{
Graph: &proto.GraphComplete{
ExternalAuthProviders: providers,
},
},
}},
}
}
t.Run("RequiredAuthMissing", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{
IncludeProvisionerDaemon: true,
ExternalAuthConfigs: []*externalauth.Config{{
InstrumentedOAuth2Config: &testutil.OAuth2Config{},
ID: "github",
Regex: regexp.MustCompile(`github\.com`),
Type: codersdk.EnhancedExternalAuthProviderGitHub.String(),
DisplayName: "GitHub",
}},
})
first := coderdtest.CreateFirstUser(t, client)
version := coderdtest.CreateTemplateVersion(t, client, first.OrganizationID,
externalAuthVersion(&proto.ExternalAuthProviderResource{Id: "github"}))
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
template := coderdtest.CreateTemplate(t, client, first.OrganizationID, version.ID)
memberClient, member := coderdtest.CreateAnotherUser(t, client, first.OrganizationID)
ctx := testutil.Context(t, testutil.WaitLong)
req := codersdk.CreateWorkspaceRequest{
TemplateID: template.ID,
Name: coderdtest.RandomUsername(t),
}
_, err := memberClient.CreateUserWorkspace(ctx, codersdk.Me, req)
var apiErr *codersdk.Error
require.ErrorAs(t, err, &apiErr)
require.Equal(t, http.StatusForbidden, apiErr.StatusCode())
require.Equal(t, externalAuthRequiredMessage, apiErr.Message)
require.Equal(t, "The workspace owner must authenticate with the following external auth providers: GitHub.", apiErr.Detail)
require.Equal(t, []codersdk.ValidationError{{
Field: "external_auth",
Detail: "github",
}}, apiErr.Validations)
// The rejection must happen before any workspace row is inserted.
_, err = memberClient.WorkspaceByOwnerAndName(ctx, codersdk.Me, req.Name, codersdk.WorkspaceOptions{})
apiErr = nil
require.ErrorAs(t, err, &apiErr)
require.Equal(t, http.StatusNotFound, apiErr.StatusCode())
// Authenticating with the provider lifts the rejection.
resp := coderdtest.RequestExternalAuthCallback(t, "github", memberClient)
_ = resp.Body.Close()
require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode)
workspace, err := memberClient.CreateUserWorkspace(ctx, codersdk.Me, req)
require.NoError(t, err)
require.Equal(t, member.ID, workspace.OwnerID)
})
t.Run("OwnerVsInitiator", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{
IncludeProvisionerDaemon: true,
ExternalAuthConfigs: []*externalauth.Config{{
InstrumentedOAuth2Config: &testutil.OAuth2Config{},
ID: "github",
Regex: regexp.MustCompile(`github\.com`),
Type: codersdk.EnhancedExternalAuthProviderGitHub.String(),
DisplayName: "GitHub",
}},
})
first := coderdtest.CreateFirstUser(t, client)
version := coderdtest.CreateTemplateVersion(t, client, first.OrganizationID,
externalAuthVersion(&proto.ExternalAuthProviderResource{Id: "github"}))
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
template := coderdtest.CreateTemplate(t, client, first.OrganizationID, version.ID)
memberClient, member := coderdtest.CreateAnotherUser(t, client, first.OrganizationID)
ctx := testutil.Context(t, testutil.WaitLong)
// The initiating admin is authenticated with the provider, but the
// workspace owner (the member) is not. Token injection at build time
// uses the owner's links, so the owner's auth state is what matters.
resp := coderdtest.RequestExternalAuthCallback(t, "github", client)
_ = resp.Body.Close()
require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode)
req := codersdk.CreateWorkspaceRequest{
TemplateID: template.ID,
Name: coderdtest.RandomUsername(t),
}
_, err := client.CreateUserWorkspace(ctx, member.Username, req)
var apiErr *codersdk.Error
require.ErrorAs(t, err, &apiErr)
require.Equal(t, http.StatusForbidden, apiErr.StatusCode())
require.Equal(t, externalAuthRequiredMessage, apiErr.Message)
// Once the owner authenticates, the same create succeeds.
resp = coderdtest.RequestExternalAuthCallback(t, "github", memberClient)
_ = resp.Body.Close()
require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode)
workspace, err := client.CreateUserWorkspace(ctx, member.Username, req)
require.NoError(t, err)
require.Equal(t, member.ID, workspace.OwnerID)
})
t.Run("OptionalProvider", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{
IncludeProvisionerDaemon: true,
ExternalAuthConfigs: []*externalauth.Config{{
InstrumentedOAuth2Config: &testutil.OAuth2Config{},
ID: "github",
Regex: regexp.MustCompile(`github\.com`),
Type: codersdk.EnhancedExternalAuthProviderGitHub.String(),
DisplayName: "GitHub",
}},
})
first := coderdtest.CreateFirstUser(t, client)
version := coderdtest.CreateTemplateVersion(t, client, first.OrganizationID,
externalAuthVersion(&proto.ExternalAuthProviderResource{Id: "github", Optional: true}))
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
template := coderdtest.CreateTemplate(t, client, first.OrganizationID, version.ID)
memberClient, member := coderdtest.CreateAnotherUser(t, client, first.OrganizationID)
ctx := testutil.Context(t, testutil.WaitLong)
// Optional providers must not block creation even when the owner has
// never authenticated with them.
workspace, err := memberClient.CreateUserWorkspace(ctx, codersdk.Me, codersdk.CreateWorkspaceRequest{
TemplateID: template.ID,
Name: coderdtest.RandomUsername(t),
})
require.NoError(t, err)
require.Equal(t, member.ID, workspace.OwnerID)
})
t.Run("InvalidToken", func(t *testing.T) {
t.Parallel()
// The validation endpoint always reports the token as revoked. The
// external auth callback stores the link without validating it, so the
// link row exists but RefreshToken classifies it as invalid.
validateSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
}))
t.Cleanup(validateSrv.Close)
client := coderdtest.New(t, &coderdtest.Options{
IncludeProvisionerDaemon: true,
ExternalAuthConfigs: []*externalauth.Config{{
InstrumentedOAuth2Config: &testutil.OAuth2Config{},
ID: "github",
Regex: regexp.MustCompile(`github\.com`),
Type: codersdk.EnhancedExternalAuthProviderGitHub.String(),
DisplayName: "GitHub",
ValidateURL: validateSrv.URL,
}},
})
first := coderdtest.CreateFirstUser(t, client)
version := coderdtest.CreateTemplateVersion(t, client, first.OrganizationID,
externalAuthVersion(&proto.ExternalAuthProviderResource{Id: "github"}))
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
template := coderdtest.CreateTemplate(t, client, first.OrganizationID, version.ID)
memberClient, _ := coderdtest.CreateAnotherUser(t, client, first.OrganizationID)
// Create the external auth link for the owner.
resp := coderdtest.RequestExternalAuthCallback(t, "github", memberClient)
_ = resp.Body.Close()
require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode)
ctx := testutil.Context(t, testutil.WaitLong)
// A link that fails validation counts as unauthenticated.
_, err := memberClient.CreateUserWorkspace(ctx, codersdk.Me, codersdk.CreateWorkspaceRequest{
TemplateID: template.ID,
Name: coderdtest.RandomUsername(t),
})
var apiErr *codersdk.Error
require.ErrorAs(t, err, &apiErr)
require.Equal(t, http.StatusForbidden, apiErr.StatusCode())
require.Equal(t, externalAuthRequiredMessage, apiErr.Message)
require.Equal(t, []codersdk.ValidationError{{
Field: "external_auth",
Detail: "github",
}}, apiErr.Validations)
})
t.Run("DisplayNameFallback", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{
IncludeProvisionerDaemon: true,
ExternalAuthConfigs: []*externalauth.Config{{
InstrumentedOAuth2Config: &testutil.OAuth2Config{},
ID: "fallback-provider",
Regex: regexp.MustCompile(`fallback\.example\.com`),
Type: codersdk.EnhancedExternalAuthProviderGitHub.String(),
}},
})
first := coderdtest.CreateFirstUser(t, client)
version := coderdtest.CreateTemplateVersion(t, client, first.OrganizationID,
externalAuthVersion(&proto.ExternalAuthProviderResource{Id: "fallback-provider"}))
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
template := coderdtest.CreateTemplate(t, client, first.OrganizationID, version.ID)
memberClient, _ := coderdtest.CreateAnotherUser(t, client, first.OrganizationID)
ctx := testutil.Context(t, testutil.WaitLong)
// Without a DisplayName, the response falls back to the provider ID.
_, err := memberClient.CreateUserWorkspace(ctx, codersdk.Me, codersdk.CreateWorkspaceRequest{
TemplateID: template.ID,
Name: coderdtest.RandomUsername(t),
})
var apiErr *codersdk.Error
require.ErrorAs(t, err, &apiErr)
require.Equal(t, http.StatusForbidden, apiErr.StatusCode())
require.Equal(t, externalAuthRequiredMessage, apiErr.Message)
require.Contains(t, apiErr.Detail, "fallback-provider")
require.Len(t, apiErr.Validations, 1)
require.Equal(t, "external_auth", apiErr.Validations[0].Field)
require.Equal(t, "fallback-provider", apiErr.Validations[0].Detail)
})
}
func TestWorkspaceByOwnerAndName(t *testing.T) {
t.Parallel()
t.Run("NotFound", func(t *testing.T) {
+6 -12
View File
@@ -87,11 +87,9 @@ func TestTracker(t *testing.T) {
var wg sync.WaitGroup
count = 0
for i := 0; i < len(ids); i++ {
wg.Add(1)
go func() {
defer wg.Done()
wg.Go(func() {
tickCh <- now
}()
})
wut.Add(ids[i])
}
@@ -173,18 +171,14 @@ func TestTracker_MultipleInstances(t *testing.T) {
nowB := now.Add(2 * time.Minute)
var wg sync.WaitGroup
var flushedA, flushedB int
wg.Add(1)
go func() {
defer wg.Done()
wg.Go(func() {
wuTickA <- nowA
flushedA = <-wuFlushA
}()
wg.Add(1)
go func() {
defer wg.Done()
})
wg.Go(func() {
wuTickB <- nowB
flushedB = <-wuFlushB
}()
})
wg.Wait()
// We expect 5 flushed IDs each
+4 -2
View File
@@ -3624,9 +3624,9 @@ func builtinPlanToolAllowed(name string, isRootChat bool) bool {
return true
case "write_file", "edit_files", "list_templates", "read_template",
"create_workspace", "start_workspace", "stop_workspace", "propose_plan", "spawn_agent",
"spawn_explore_agent", "wait_agent", "ask_user_question", "attach_file":
"spawn_explore_agent", "wait_agent", "list_agents", "ask_user_question", "attach_file":
return isRootChat
case "process_list", "process_signal", "message_agent", "close_agent",
case "process_list", "process_signal", "message_agent", "interrupt_agent", "close_agent",
"spawn_computer_use_agent":
return false
default:
@@ -3708,7 +3708,9 @@ func allowedExploreToolNames(allTools []fantasy.AgentTool) []string {
"spawn_agent": false,
"wait_agent": false,
"message_agent": false,
"interrupt_agent": false,
"close_agent": false,
"list_agents": false,
"read_skill": true,
"read_skill_file": true,
"ask_user_question": false,
+3 -1
View File
@@ -428,7 +428,8 @@ func TestActiveToolNamesForTurn(t *testing.T) {
"spawn_agent",
"wait_agent",
"message_agent",
"close_agent",
"interrupt_agent",
"list_agents",
"read_skill",
"read_skill_file",
"ask_user_question",
@@ -448,6 +449,7 @@ func TestActiveToolNamesForTurn(t *testing.T) {
"propose_plan",
"spawn_agent",
"wait_agent",
"list_agents",
"read_skill",
"read_skill_file",
"ask_user_question",
+19 -106
View File
@@ -11,7 +11,6 @@ import (
"io"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"slices"
@@ -78,87 +77,6 @@ func testAPIKeyID(t testing.TB, db database.Store, userID uuid.UUID) string {
return key.ID
}
type chatAIGatewayRecordedRequest struct {
ProviderName string
Source aibridge.Source
APIKeyID string
Path string
Authorization string
XAPIKey string
CoderToken string
}
type chatAIGatewayTestFactory struct {
target *url.URL
transport http.RoundTripper
preservePath bool
mu sync.Mutex
requests []chatAIGatewayRecordedRequest
}
func newChatAIGatewayTestFactory(t testing.TB, targetBaseURL string) *chatAIGatewayTestFactory {
t.Helper()
target, err := url.Parse(targetBaseURL)
require.NoError(t, err)
return &chatAIGatewayTestFactory{target: target, transport: http.DefaultTransport}
}
func newChatAIGatewayPreservePathTestFactory(t testing.TB, targetBaseURL string) *chatAIGatewayTestFactory {
t.Helper()
target, err := url.Parse(targetBaseURL)
require.NoError(t, err)
return &chatAIGatewayTestFactory{target: target, transport: http.DefaultTransport, preservePath: true}
}
func (f *chatAIGatewayTestFactory) TransportFor(providerName string, source aibridge.Source) (http.RoundTripper, error) {
return chatAIGatewayRoundTripper{factory: f, providerName: providerName, source: source}, nil
}
func (f *chatAIGatewayTestFactory) requestsSnapshot() []chatAIGatewayRecordedRequest {
f.mu.Lock()
defer f.mu.Unlock()
return slices.Clone(f.requests)
}
type chatAIGatewayRoundTripper struct {
factory *chatAIGatewayTestFactory
providerName string
source aibridge.Source
}
func (t chatAIGatewayRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
apiKeyID, _ := aibridge.DelegatedAPIKeyIDFromContext(req.Context())
t.factory.mu.Lock()
t.factory.requests = append(t.factory.requests, chatAIGatewayRecordedRequest{
ProviderName: t.providerName,
Source: t.source,
APIKeyID: apiKeyID,
Path: req.URL.Path,
Authorization: req.Header.Get("Authorization"),
XAPIKey: req.Header.Get("X-Api-Key"),
CoderToken: req.Header.Get(aibridge.HeaderCoderToken),
})
t.factory.mu.Unlock()
targetURL := *t.factory.target
if t.factory.preservePath {
targetURL.Path = req.URL.Path
} else {
targetURL.Path = strings.TrimPrefix(req.URL.Path, "/v1")
if targetURL.Path == "" {
targetURL.Path = "/"
}
}
targetURL.RawQuery = req.URL.RawQuery
cloned := req.Clone(req.Context())
cloned.URL = &targetURL
cloned.Host = t.factory.target.Host
return t.factory.transport.RoundTrip(cloned)
}
func chatAIGatewayTransportFactoryPointer(factory aibridge.TransportFactory) *atomic.Pointer[aibridge.TransportFactory] {
var ptr atomic.Pointer[aibridge.TransportFactory]
ptr.Store(&factory)
@@ -387,7 +305,7 @@ func TestSubagentChatExcludesWorkspaceProvisioningTools(t *testing.T) {
"list_templates", "read_template", "create_workspace",
"start_workspace", "stop_workspace",
}
subagentTools := []string{"spawn_agent", "wait_agent", "message_agent", "close_agent"}
subagentTools := []string{"spawn_agent", "wait_agent", "message_agent", "interrupt_agent", "list_agents"}
// Identify root and subagent calls. Root chat calls include
// spawn_agent; the subagent call does not. Because the root chat
@@ -5401,7 +5319,7 @@ func TestActiveServer_AIGatewayRoutingPreservesAPIKeyAfterCompaction(t *testing.
return chattest.AnthropicStreamingResponse()
}
})
factory := newChatAIGatewayPreservePathTestFactory(t, anthropicURL)
factory := chattest.NewMockAIBridgeTransport(t, anthropicURL, chattest.WithPreservePath())
user, org, model := seedAnthropicChatDependencies(t, db, anthropicURL)
model = updateChatModelCompressionThreshold(t, db, model, contextLimit, thresholdPercent)
provider, err := db.GetAIProviderByID(ctx, model.AIProviderID.UUID)
@@ -5480,14 +5398,14 @@ func TestActiveServer_AIGatewayRoutingPreservesAPIKeyAfterCompaction(t *testing.
require.True(t, compressed.summaries[0].APIKeyID.Valid)
require.Equal(t, apiKey.ID, compressed.summaries[0].APIKeyID.String)
requests := factory.requestsSnapshot()
requests := factory.RequestsSnapshot()
require.NotEmpty(t, requests)
for _, req := range requests {
require.Equal(t, provider.Name, req.ProviderName)
require.Equal(t, aibridge.SourceAgents, req.Source)
require.Equal(t, apiKey.ID, req.APIKeyID)
require.Equal(t, "sk-user-aibridge", req.XAPIKey)
require.Equal(t, "delegated", req.CoderToken)
require.Equal(t, "sk-user-aibridge", req.Request.Header.Get("X-Api-Key"))
require.Equal(t, "delegated", req.Request.Header.Get(aibridge.HeaderCoderToken))
}
}
@@ -9537,7 +9455,7 @@ func TestComputerUseSubagentToolsAndModel(t *testing.T) {
// 5. Verify subagent tools are NOT present.
subagentTools := []string{
"spawn_agent",
"wait_agent", "message_agent", "close_agent",
"wait_agent", "message_agent", "interrupt_agent", "list_agents",
}
for _, tool := range subagentTools {
require.NotContains(t, childTools, tool,
@@ -9790,7 +9708,7 @@ func TestProcessChat_AIGatewayRoutingUsesDelegatedAPIKey(t *testing.T) {
}
return chattest.OpenAINonStreamingResponse(`{"title":"AI Gateway Chat"}`)
})
factory := newChatAIGatewayTestFactory(t, openAIURL)
factory := chattest.NewMockAIBridgeTransport(t, openAIURL)
user, org, provider, model, apiKey := seedAIGatewayOpenAITestDependencies(t, db, openAIURL)
@@ -9824,24 +9742,19 @@ func TestProcessChat_AIGatewayRoutingUsesDelegatedAPIKey(t *testing.T) {
require.Equal(t, database.ChatStatusWaiting, chatResult.Status)
require.False(t, chatResult.LastError.Valid)
requests := factory.requestsSnapshot()
requests := factory.RequestsSnapshot()
require.NotEmpty(t, requests)
require.Contains(t, requests, chatAIGatewayRecordedRequest{
ProviderName: provider.Name,
Source: aibridge.SourceAgents,
APIKeyID: apiKey.ID,
Path: "/v1/responses",
Authorization: "Bearer sk-user-aibridge",
CoderToken: "delegated",
})
require.True(t, slices.ContainsFunc(requests, func(req chattest.RecordedRequest) bool {
return req.Request.URL.Path == "/v1/responses"
}), "no request to /v1/responses found")
for _, req := range requests {
require.Equal(t, provider.Name, req.ProviderName)
require.Equal(t, aibridge.SourceAgents, req.Source)
require.Equal(t, apiKey.ID, req.APIKeyID)
require.Equal(t, "Bearer sk-user-aibridge", req.Authorization)
require.Empty(t, req.XAPIKey)
require.Equal(t, "delegated", req.CoderToken)
require.True(t, strings.HasPrefix(req.Path, "/v1/"), "unexpected aibridge path %q", req.Path)
require.Equal(t, "Bearer sk-user-aibridge", req.Request.Header.Get("Authorization"))
require.Empty(t, req.Request.Header.Get("X-Api-Key"))
require.Equal(t, "delegated", req.Request.Header.Get(aibridge.HeaderCoderToken))
require.True(t, strings.HasPrefix(req.Request.URL.Path, "/v1/"), "unexpected aibridge path %q", req.Request.URL.Path)
}
}
@@ -9859,7 +9772,7 @@ func TestProcessChat_AIGatewayRoutingPreservesAPIKeyAfterWorkspaceContext(t *tes
}
return chattest.OpenAINonStreamingResponse(`{"title":"AI Gateway Workspace"}`)
})
factory := newChatAIGatewayTestFactory(t, openAIURL)
factory := chattest.NewMockAIBridgeTransport(t, openAIURL)
user, org, provider, model, apiKey := seedAIGatewayOpenAITestDependencies(t, db, openAIURL)
ws, dbAgent := seedWorkspaceWithAgent(t, db, user.ID)
@@ -9908,14 +9821,14 @@ func TestProcessChat_AIGatewayRoutingPreservesAPIKeyAfterWorkspaceContext(t *tes
require.NoError(t, err)
require.NotEmpty(t, pinned, "workspace context should be pinned to the chat")
requests := factory.requestsSnapshot()
requests := factory.RequestsSnapshot()
require.NotEmpty(t, requests)
for _, req := range requests {
require.Equal(t, provider.Name, req.ProviderName)
require.Equal(t, aibridge.SourceAgents, req.Source)
require.Equal(t, apiKey.ID, req.APIKeyID)
require.Equal(t, "Bearer sk-user-aibridge", req.Authorization)
require.Equal(t, "delegated", req.CoderToken)
require.Equal(t, "Bearer sk-user-aibridge", req.Request.Header.Get("Authorization"))
require.Equal(t, "delegated", req.Request.Header.Get(aibridge.HeaderCoderToken))
}
}
+25 -7
View File
@@ -260,6 +260,11 @@ type ExecuteLocalToolsOptions struct {
// case a default budget applies.
ContextLimit int64
// ToolNameAliases maps a non-advertised tool name to the canonical
// tool it dispatches to. Used for backward compatibility when a tool
// is renamed but old chat histories still reference the old name.
ToolNameAliases map[string]string
PublishMessagePart func(codersdk.ChatMessageRole, codersdk.ChatMessagePart)
Logger slog.Logger
Metrics *Metrics
@@ -540,6 +545,7 @@ func ExecuteLocalTools(ctx context.Context, opts ExecuteLocalToolsOptions) (Tool
modelName,
opts.BuiltinToolNames,
maxResultBytes,
opts.ToolNameAliases,
func(tr fantasy.ToolResultContent, completedAt time.Time) {
recordToolResultTimestamp(&result, tr.ToolCallID, completedAt)
publishToolAttachments(ctx, opts.Logger, tr, completedAt, publishMessagePart)
@@ -1006,6 +1012,7 @@ func executeTools(
provider, model string,
builtinToolNames map[string]bool,
maxResultBytes int,
toolNameAliases map[string]string,
onResult func(fantasy.ToolResultContent, time.Time),
) []fantasy.ToolResultContent {
if len(toolCalls) == 0 {
@@ -1085,6 +1092,7 @@ func executeTools(
providerRunnerNames,
resultProviderMetadata,
maxResultBytes,
toolNameAliases,
)
}()
}
@@ -1205,6 +1213,7 @@ func executeSingleTool(
providerRunnerNames map[string]struct{},
resultProviderMetadata map[string]func(fantasy.ToolResponse) fantasy.ProviderMetadata,
maxResultBytes int,
toolNameAliases map[string]string,
) fantasy.ToolResultContent {
result := fantasy.ToolResultContent{
ToolCallID: tc.ToolCallID,
@@ -1224,31 +1233,40 @@ func executeSingleTool(
}
}()
_, isProviderRunner := providerRunnerNames[tc.ToolName]
if !isProviderRunner && !isToolActive(tc.ToolName, activeTools) {
// Resolve backward-compatible tool aliases (for example a renamed
// tool whose old name still appears in chat history) to the canonical
// tool before the active-tool and dispatch lookups.
resolvedName := tc.ToolName
if alias, ok := toolNameAliases[tc.ToolName]; ok {
resolvedName = alias
}
_, isProviderRunner := providerRunnerNames[resolvedName]
if !isProviderRunner && !isToolActive(resolvedName, activeTools) {
result.Result = fantasy.ToolResultOutputContentError{
Error: xerrors.New("Tool not active in this turn: " + tc.ToolName),
Error: xerrors.New("Tool not active in this turn: " + resolvedName),
}
return result
}
tool, exists := toolMap[tc.ToolName]
tool, exists := toolMap[resolvedName]
if !exists {
result.Result = fantasy.ToolResultOutputContentError{
Error: xerrors.New("Tool not found: " + tc.ToolName),
Error: xerrors.New("Tool not found: " + resolvedName),
}
return result
}
logger.Debug(ctx, "tool execution",
slog.F("tool_name", tc.ToolName),
slog.F("resolved_tool_name", resolvedName),
slog.F("tool_call_id", tc.ToolCallID),
slog.F("builtin", builtinToolNames[tc.ToolName]),
slog.F("builtin", builtinToolNames[resolvedName]),
slog.F("is_provider_runner", isProviderRunner),
)
resp, err := tool.Run(ctx, fantasy.ToolCall{
ID: tc.ToolCallID,
Name: tc.ToolName,
Name: resolvedName,
Input: tc.Input,
})
if err != nil {
@@ -915,6 +915,7 @@ func TestExecuteSingleTool_MediaBase64Encoding(t *testing.T) {
map[string]struct{}{},
nil,
defaultToolResultBytes,
nil,
)
media, ok := result.Result.(fantasy.ToolResultOutputContentMedia)
@@ -963,6 +964,7 @@ func TestExecuteSingleTool_MediaBase64Encoding(t *testing.T) {
map[string]struct{}{},
nil,
defaultToolResultBytes,
nil,
)
media, ok := result.Result.(fantasy.ToolResultOutputContentMedia)
@@ -1006,6 +1008,7 @@ func TestExecuteSingleTool_MediaBase64Encoding(t *testing.T) {
map[string]struct{}{},
nil,
defaultToolResultBytes,
nil,
)
textOutput, ok := result.Result.(fantasy.ToolResultOutputContentText)
@@ -1015,3 +1018,88 @@ func TestExecuteSingleTool_MediaBase64Encoding(t *testing.T) {
require.Contains(t, textOutput.Text, "world")
})
}
func TestExecuteSingleTool_ResolvesToolNameAlias(t *testing.T) {
t.Parallel()
metrics := NewMetrics(prometheus.NewRegistry())
logger := slog.Make()
var gotName string
tool := fantasy.NewAgentTool(
"interrupt_agent",
"interrupts an agent",
func(_ context.Context, _ struct{}, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
gotName = call.Name
return fantasy.ToolResponse{Content: `{"interrupted":true}`}, nil
},
)
toolMap := map[string]fantasy.AgentTool{"interrupt_agent": tool}
// The model emits the deprecated name from old history; only the
// canonical name is advertised/active.
tc := fantasy.ToolCallContent{
ToolCallID: "call-alias",
ToolName: "close_agent",
Input: "{}",
}
result := executeSingleTool(
context.Background(),
toolMap,
tc,
metrics,
logger,
"fake", "fake-model",
map[string]bool{},
[]string{"interrupt_agent"},
map[string]struct{}{},
nil,
defaultToolResultBytes,
map[string]string{"close_agent": "interrupt_agent"},
)
textOutput, ok := result.Result.(fantasy.ToolResultOutputContentText)
require.True(t, ok, "expected text output, got %T", result.Result)
require.Contains(t, textOutput.Text, "interrupted")
// The handler receives the resolved canonical name.
require.Equal(t, "interrupt_agent", gotName)
// The persisted result keeps the original alias so existing history
// renders consistently.
require.Equal(t, "close_agent", result.ToolName)
}
func TestExecuteSingleTool_UnknownAliasFallsThrough(t *testing.T) {
t.Parallel()
metrics := NewMetrics(prometheus.NewRegistry())
logger := slog.Make()
tc := fantasy.ToolCallContent{
ToolCallID: "call-missing",
ToolName: "close_agent",
Input: "{}",
}
// No alias provided: the deprecated name is neither active nor in the
// tool map, so it surfaces a clear not-active error and the model can
// self-correct to the advertised name.
result := executeSingleTool(
context.Background(),
map[string]fantasy.AgentTool{},
tc,
metrics,
logger,
"fake", "fake-model",
map[string]bool{},
[]string{"interrupt_agent"},
map[string]struct{}{},
nil,
defaultToolResultBytes,
nil,
)
errOutput, ok := result.Result.(fantasy.ToolResultOutputContentError)
require.True(t, ok, "expected error output, got %T", result.Result)
require.Contains(t, errOutput.Error.Error(), "close_agent")
}
+12 -6
View File
@@ -7,19 +7,25 @@ import (
const (
// toolResultContextDivisor bounds how much of a model's context
// window a single tool result may occupy: at most 1/N of the
// window a single tool result may occupy: at most 1/3 of the
// window. This caps a single oversized result (most often a large
// MCP response) so it cannot overflow the prompt on its own, while
// still letting a generous amount of output through. Cumulative
// still letting a useful amount of output through. The divisor is
// deliberately larger than 2 so the absolute cap stays sane on
// large-context (e.g. 1M-token) models, where a more generous
// fraction would still admit multi-megabyte results. Cumulative
// growth across many results is handled separately by context
// compaction.
toolResultContextDivisor = 2
toolResultContextDivisor = 3
// bytesPerTokenEstimate converts a token budget into a byte budget.
// Tool output is capped before tokenization, so this is a coarse,
// provider-agnostic estimate. Roughly 4 bytes per token for typical
// text.
bytesPerTokenEstimate = 4
// provider-agnostic estimate. It is deliberately conservative at ~3
// bytes per token: dense payloads (JSON, logs, code, non-ASCII) run
// well below 4 bytes per token, so the lower estimate yields a
// smaller byte budget that is less likely to underestimate the true
// token cost.
bytesPerTokenEstimate = 3
// minToolResultBytes is the floor for the per-result byte budget so
// small or unknown context windows still let useful output through.
+4 -2
View File
@@ -902,10 +902,12 @@ func matchingAttachmentForMedia(
return chattool.AttachmentMetadata{}, false
}
// Keep in sync with coderd/x/chatd/subagent.go.
// isSubagentLifecycleToolName lists subagent tools whose error results
// may carry structured JSON. Keep in sync with coderd/x/chatd/subagent.go.
// See subagentToolNameAliases for the full alias map.
func isSubagentLifecycleToolName(name string) bool {
switch name {
case "spawn_agent", "wait_agent", "message_agent", "close_agent":
case "spawn_agent", "wait_agent", "message_agent", "interrupt_agent", "close_agent":
return true
default:
return false
@@ -0,0 +1,125 @@
package chattest
import (
"net/http"
"net/url"
"slices"
"strings"
"sync"
"testing"
"golang.org/x/xerrors"
"github.com/coder/coder/v2/coderd/aibridge"
)
// RecordedRequest captures metadata from a single request that passed
// through the mock transport factory. Fields not already available on
// [http.Request] are included here; tests can access headers and path
// via [RecordedRequest.Request].
type RecordedRequest struct {
// Request is a clone of the original [http.Request].
Request *http.Request
// ProviderName is the AI provider instance name passed to
// [TransportFactory.TransportFor].
ProviderName string
// Source is the aibridge source passed to TransportFor.
Source aibridge.Source
// APIKeyID is the delegated API key ID attached to request ctx.
APIKeyID string
}
// MockAIBridgeTransportOption configures a [MockAIBridgeTransport].
type MockAIBridgeTransportOption func(*MockAIBridgeTransport)
// WithPreservePath disables the default "/v1" path stripping so the
// target server receives the full original request path.
func WithPreservePath() MockAIBridgeTransportOption {
return func(f *MockAIBridgeTransport) { f.preservePath = true }
}
// MockAIBridgeTransport is a test [aibridge.TransportFactory] that
// redirects requests to a target URL (typically a [chattest.NewOpenAI]
// or [chattest.NewAnthropic] server) and records each request for
// later inspection.
//
// By default it strips the leading "/v1" path segment before
// forwarding, matching how the real AI Gateway transport rewrites
// upstream-shaped requests. Pass [WithPreservePath] when the target
// server expects the full original path.
type MockAIBridgeTransport struct {
target *url.URL
transport http.RoundTripper
preservePath bool
mu sync.Mutex
requests []RecordedRequest
}
// NewMockAIBridgeTransport creates a [MockAIBridgeTransport] that
// forwards to targetBaseURL.
func NewMockAIBridgeTransport(t testing.TB, targetBaseURL string, opts ...MockAIBridgeTransportOption) *MockAIBridgeTransport {
t.Helper()
target, err := url.Parse(targetBaseURL)
if err != nil {
t.Fatalf("parse target URL: %v", err)
}
f := &MockAIBridgeTransport{target: target, transport: http.DefaultTransport}
for _, opt := range opts {
opt(f)
}
return f
}
// TransportFor implements [aibridge.TransportFactory].
func (f *MockAIBridgeTransport) TransportFor(providerName string, source aibridge.Source) (http.RoundTripper, error) {
if len(providerName) == 0 {
return nil, xerrors.New("provider name is required")
}
return mockRoundTripper{factory: f, providerName: providerName, source: source}, nil
}
// RequestsSnapshot returns a copy of all recorded requests.
func (f *MockAIBridgeTransport) RequestsSnapshot() []RecordedRequest {
f.mu.Lock()
defer f.mu.Unlock()
return slices.Clone(f.requests)
}
type mockRoundTripper struct {
factory *MockAIBridgeTransport
providerName string
source aibridge.Source
}
func (rt mockRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
// Mirror the real aibridged transport: a delegated API key must be
// on the context, otherwise aibridged has no identity to act under.
apiKeyID, ok := aibridge.DelegatedAPIKeyIDFromContext(req.Context())
if !ok {
return nil, xerrors.New("mock aibridged transport requires WithDelegatedAPIKeyID on the request context")
}
rt.factory.mu.Lock()
rt.factory.requests = append(rt.factory.requests, RecordedRequest{
Request: req.Clone(req.Context()),
ProviderName: rt.providerName,
Source: rt.source,
APIKeyID: apiKeyID,
})
rt.factory.mu.Unlock()
targetURL := *rt.factory.target
if rt.factory.preservePath {
targetURL.Path = req.URL.Path
} else {
targetURL.Path = strings.TrimPrefix(req.URL.Path, "/v1")
if targetURL.Path == "" {
targetURL.Path = "/"
}
}
targetURL.RawQuery = req.URL.RawQuery
cloned := req.Clone(req.Context())
cloned.URL = &targetURL
cloned.Host = rt.factory.target.Host
return rt.factory.transport.RoundTrip(cloned)
}
+2 -4
View File
@@ -416,12 +416,10 @@ func TestConfigCache_Singleflight(t *testing.T) {
var wg sync.WaitGroup
start := make(chan struct{})
for i := 0; i < callers; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
wg.Go(func() {
<-start
results[i], errs[i] = cache.EnabledProviders(ctx)
}(i)
})
}
close(start)
+1
View File
@@ -670,6 +670,7 @@ func (s *taskStarter) executeLocalTools(
ModelProvider: provider,
ModelName: modelName,
ContextLimit: prepared.ContextLimitFallback,
ToolNameAliases: subagentToolNameAliases,
PublishMessagePart: publish,
Logger: s.opts.Logger,
Metrics: s.server.metrics,
@@ -132,6 +132,15 @@ func TestDefaultSystemPromptContainsVersionControlSafety(t *testing.T) {
require.Contains(t, DefaultSystemPrompt, "Never treat the original request as confirmation")
}
func TestDefaultSystemPromptContainsSubagentOrchestration(t *testing.T) {
t.Parallel()
require.Contains(t, DefaultSystemPrompt, "<subagent-orchestration>")
require.Contains(t, DefaultSystemPrompt, "</subagent-orchestration>")
require.Contains(t, DefaultSystemPrompt, "An error status is often recoverable")
require.Contains(t, DefaultSystemPrompt, "call list_agents to recover them")
}
func TestWorkspaceAwarenessDelaysWorkspaceCreation(t *testing.T) {
t.Parallel()
+11 -1
View File
@@ -4,6 +4,14 @@ import "github.com/coder/coder/v2/coderd/x/chatd/chattool"
const defaultSystemPromptPlanPathBlockPlaceholder = "{{CODER_CHAT_PLAN_FILE_PATH_BLOCK}}"
// subagentOrchestrationPromptBlock is the root-only orchestration guidance.
// Delegated child chats cannot call list_agents or message_agent, so this
// block is stripped from their system prompt at creation time.
const subagentOrchestrationPromptBlock = `<subagent-orchestration>
An error status is often recoverable. Resume the agent with message_agent to retry; treat only genuine, repeating failures as terminal.
If you lose track of your spawned agents, call list_agents to recover them before finishing.
</subagent-orchestration>`
const workspaceAttachedAwareness = "This chat is attached to a workspace. You can use workspace tools like execute, read_file, write_file, etc."
const workspaceDetachedAwarenessBase = `No workspace is attached to this chat yet.
@@ -131,7 +139,9 @@ Once a workspace is available:
Write the file first, then present it. All file paths must be absolute.
When the <plan-file-path> block below is present, use that exact path.
` + defaultSystemPromptPlanPathBlockPlaceholder + `
</planning>`
</planning>
` + subagentOrchestrationPromptBlock
var planningOverlayPrompt = `You are in Plan Mode.
Every response must work toward producing a plan.
+6 -2
View File
@@ -590,8 +590,12 @@ func TestWaitAgentTimeoutLeavesRecordingRunning(t *testing.T) {
result := testutil.RequireReceive(ctx, t, resultCh)
require.NoError(t, result.err)
assert.True(t, result.resp.IsError, "expected error response on timeout")
assert.Contains(t, result.resp.Content, "timed out")
// On timeout the agent is still working, so wait_agent now
// returns a non-error payload rather than a tool error. The
// recording is intentionally left running: the gomock controller
// fails the test if StopDesktopRecording is called.
require.False(t, result.resp.IsError, "timeout must return a non-error payload, not an error")
assert.Contains(t, result.resp.Content, `"timed_out":true`)
}
// TestStopAndStoreRecording_Oversized verifies that when the
+251 -55
View File
@@ -31,6 +31,30 @@ import (
var ErrSubagentNotDescendant = xerrors.New("target chat is not a descendant of current chat")
// ErrSubagentWaitTimeout is returned by awaitSubagentCompletion when the
// wait deadline elapses before the subagent reaches a terminal status. The
// agent is still working and the wait can be retried.
var ErrSubagentWaitTimeout = xerrors.New("timed out waiting for delegated subagent completion")
// subagentToolNameAliases maps deprecated subagent tool names to their
// current names so historical close_agent calls in chat history still
// dispatch to interrupt_agent without advertising the old name in the
// tool list.
var subagentToolNameAliases = map[string]string{
"close_agent": "interrupt_agent",
}
// subagentStatusError wraps a subagent that reached error status. It
// carries the chat and report so callers can surface a structured,
// recoverable-aware payload instead of a bare tool error.
type subagentStatusError struct {
chat database.Chat
report string
reason string
}
func (e *subagentStatusError) Error() string { return e.reason }
var errInvalidModelOverrideMetadata = xerrors.New("invalid model override metadata")
type modelOverrideConfigResolver func(
@@ -48,6 +72,10 @@ const (
subagentAwaitPollInterval = 200 * time.Millisecond
subagentAwaitFallbackPoll = 5 * time.Second
defaultSubagentWaitTimeout = 5 * time.Minute
defaultListAgentsLimit = 10
maxListAgentsLimit = 50
subagentRecordingStopTimeout = 90 * time.Second
)
// computerUseSubagentSystemPrompt is the system prompt prepended to
@@ -77,10 +105,15 @@ type messageAgentArgs struct {
Interrupt bool `json:"interrupt,omitempty"`
}
type closeAgentArgs struct {
type interruptAgentArgs struct {
ChatID string `json:"chat_id"`
}
type listAgentsArgs struct {
Limit *int `json:"limit,omitempty"`
Offset *int `json:"offset,omitempty"`
}
func (p *Server) isDesktopEnabled(ctx context.Context) bool {
enabled, err := p.db.GetChatDesktopEnabled(ctx)
if err != nil {
@@ -609,9 +642,9 @@ func (p *Server) subagentTools(
fantasy.NewAgentTool(
"wait_agent",
"Wait until a spawned child agent finishes its task. "+
"Returns the agent's final response and status. "+
"Call this after "+spawnAgentToolName+" to collect the "+
"result before continuing your own work.",
"Returns the agent's response and status. A timeout is not "+
"a failure: the agent is still running. Call wait_agent again "+
"or use list_agents to check its status.",
func(ctx context.Context, args waitAgentArgs, _ fantasy.ToolCall) (fantasy.ToolResponse, error) {
if currentChat == nil {
return fantasy.NewTextErrorResponse("subagent callbacks are not configured"), nil
@@ -694,41 +727,63 @@ func (p *Server) subagentTools(
// On timeout or error, leave the recording running on
// the agent so the next wait_agent call continues it.
if awaitErr != nil {
if xerrors.Is(awaitErr, ErrSubagentWaitTimeout) {
// The agent may have completed in the gap between
// the last poll and the timer firing. Re-check
// completion with a fresh DB read to avoid acting
// on a stale status (TOCTOU).
checkedChat, checkedReport, done, checkErr := p.checkSubagentCompletion(ctx, targetChatID)
if checkErr != nil {
return subagentErrorResponse(checkErr, targetChatInfo), nil
}
if !done {
return toolJSONResponse(withSubagentType(map[string]any{
"chat_id": targetChatID.String(),
"title": checkedChat.Title,
"status": string(checkedChat.Status),
"timed_out": true,
}, checkedChat)), nil
}
// The agent completed in the gap. Classify through
// the same handler as the normal poll path. If the
// agent errored, handleSubagentDone returns a
// subagentStatusError that the error-status block
// below catches.
targetChat, report, awaitErr = handleSubagentDone(checkedChat, checkedReport)
if awaitErr == nil {
return p.waitAgentSuccessResponse(ctx, recordingID, agentConn, parent, targetChat, report), nil
}
}
if errStatus, ok := errors.AsType[*subagentStatusError](awaitErr); ok {
errChat := errStatus.chat
lastError := subagentLastErrorMessage(errChat.LastError)
if lastError == "" {
lastError = errStatus.reason
}
return toolJSONResponse(withSubagentType(map[string]any{
"chat_id": errChat.ID.String(),
"title": errChat.Title,
"status": string(errChat.Status),
"last_error": lastError,
"report": errStatus.report,
}, errChat)), nil
}
return subagentErrorResponse(awaitErr, targetChatInfo), nil
}
// Only stop and store the recording on success.
var recResult recordingResult
if recordingID != "" && agentConn != nil {
// Use a fresh context for cleanup so a canceled
// parent context does not prevent recording storage.
stopCtx, stopCancel := context.WithTimeout(context.WithoutCancel(ctx), 90*time.Second)
defer stopCancel()
recResult = p.stopAndStoreRecording(stopCtx, agentConn,
recordingID, parent.ID, parent.OwnerID, parent.WorkspaceID)
}
resp := withSubagentType(map[string]any{
"chat_id": targetChat.ID.String(),
"title": targetChat.Title,
"report": report,
"status": string(targetChat.Status),
}, targetChat)
if recResult.recordingFileID != "" {
resp["recording_file_id"] = recResult.recordingFileID
}
if recResult.thumbnailFileID != "" {
resp["thumbnail_file_id"] = recResult.thumbnailFileID
}
return toolJSONResponse(resp), nil
return p.waitAgentSuccessResponse(ctx, recordingID, agentConn, parent, targetChat, report), nil
},
),
fantasy.NewAgentTool(
"message_agent",
"Send a follow-up message to a previously spawned child "+
"agent. Use this to provide additional instructions, "+
"corrections, or context to a running or completed "+
"agent. After sending, use wait_agent to collect the "+
"updated response.",
"agent. If the agent is idle, it resumes work on the "+
"message. If the agent is busy, the message is queued and "+
"processed after current work. Set interrupt to true to "+
"stop the agent's current work; the message is queued and "+
"processed next, after any already-queued messages. "+
"After sending, use wait_agent to retrieve the response.",
func(ctx context.Context, args messageAgentArgs, _ fantasy.ToolCall) (fantasy.ToolResponse, error) {
if currentChat == nil {
return fantasy.NewTextErrorResponse("subagent callbacks are not configured"), nil
@@ -764,20 +819,26 @@ func (p *Server) subagentTools(
return subagentErrorResponse(err, targetChatInfo), nil
}
interrupted := false
if args.Interrupt && targetChatInfo != nil {
interrupted = targetChatInfo.Status == database.ChatStatusRunning ||
targetChatInfo.Status == database.ChatStatusPending
}
return toolJSONResponse(withSubagentType(map[string]any{
"chat_id": targetChat.ID.String(),
"title": targetChat.Title,
"status": string(targetChat.Status),
"interrupted": args.Interrupt,
"interrupted": interrupted,
}, targetChat)), nil
},
),
fantasy.NewAgentTool(
"close_agent",
"Immediately stop a spawned child agent. Use this to "+
"cancel a subagent that is stuck, no longer needed, "+
"or working on the wrong approach.",
func(ctx context.Context, args closeAgentArgs, _ fantasy.ToolCall) (fantasy.ToolResponse, error) {
"interrupt_agent",
"Interrupt a spawned child agent's current work. The "+
"status may briefly read interrupting before transitioning "+
"to waiting, or running if there are queued messages. "+
"Resume with message_agent or leave it idle.",
func(ctx context.Context, args interruptAgentArgs, _ fantasy.ToolCall) (fantasy.ToolResponse, error) {
if currentChat == nil {
return fantasy.NewTextErrorResponse("subagent callbacks are not configured"), nil
}
@@ -792,12 +853,12 @@ func (p *Server) subagentTools(
if chat, lookupErr := p.db.GetChatByID(ctx, targetChatID); lookupErr == nil {
targetChatInfo = &chat
} else if !xerrors.Is(lookupErr, sql.ErrNoRows) {
p.logger.Warn(ctx, "unexpected error looking up chat for close",
p.logger.Warn(ctx, "unexpected error looking up chat for interrupt",
slog.F("chat_id", targetChatID),
slog.Error(lookupErr),
)
}
targetChat, err := p.closeSubagent(
targetChat, interrupted, err := p.interruptSubagent(
ctx,
parent.ID,
targetChatID,
@@ -807,13 +868,85 @@ func (p *Server) subagentTools(
}
return toolJSONResponse(withSubagentType(map[string]any{
"chat_id": targetChat.ID.String(),
"title": targetChat.Title,
"terminated": true,
"status": string(targetChat.Status),
"chat_id": targetChat.ID.String(),
"title": targetChat.Title,
"interrupted": interrupted,
"status": string(targetChat.Status),
}, targetChat)), nil
},
),
fantasy.NewAgentTool(
"list_agents",
"List the child agents spawned by this chat, most recently "+
"active first. Returns up to `limit` agents (default 10) "+
"with `total` and `has_more`; use `offset` to page. The "+
"sort order is best-effort: an agent's position may shift "+
"if its updated_at changes between calls. Each "+
"agent has chat_id, title, type, status, created_at, "+
"updated_at. Status: pending/running = working, "+
"interrupting = transient, waiting/completed = idle, "+
"error = stopped on error.",
func(ctx context.Context, args listAgentsArgs, _ fantasy.ToolCall) (fantasy.ToolResponse, error) {
if currentChat == nil {
return fantasy.NewTextErrorResponse("subagent callbacks are not configured"), nil
}
limit := defaultListAgentsLimit
if args.Limit != nil {
limit = min(max(*args.Limit, 1), maxListAgentsLimit)
}
offset := 0
if args.Offset != nil && *args.Offset > 0 {
offset = *args.Offset
}
parent := currentChat()
if parent.ParentChatID.Valid {
return fantasy.NewTextErrorResponse("list_agents is only available on root chats"), nil
}
rows, err := p.db.GetChildChatsByParentIDs(ctx, database.GetChildChatsByParentIDsParams{
ParentIds: []uuid.UUID{parent.ID},
// Exclude archived children by default. Do not pass an
// invalid NullBool, which would include archived rows.
Archived: sql.NullBool{Bool: false, Valid: true},
})
if err != nil {
return fantasy.NewTextErrorResponse(xerrors.Errorf("list child agents: %w", err).Error()), nil
}
slices.SortStableFunc(rows, func(a, b database.GetChildChatsByParentIDsRow) int {
if c := b.Chat.UpdatedAt.Compare(a.Chat.UpdatedAt); c != 0 {
return c
}
return strings.Compare(b.Chat.ID.String(), a.Chat.ID.String())
})
total := len(rows)
start := min(offset, total)
end := min(start+limit, total)
page := rows[start:end]
agents := make([]map[string]any, 0, len(page))
for _, row := range page {
child := row.Chat
agents = append(agents, withSubagentType(map[string]any{
"chat_id": child.ID.String(),
"title": child.Title,
"status": string(child.Status),
"created_at": child.CreatedAt.Format(time.RFC3339),
"updated_at": child.UpdatedAt.Format(time.RFC3339),
}, child))
}
return toolJSONResponse(map[string]any{
"agents": agents,
"total": total,
"returned": len(agents),
"offset": offset,
"has_more": end < total,
}), nil
},
),
}
}
@@ -988,6 +1121,9 @@ func (p *Server) createChildSubagentChatWithOptions(
// child chat creation does not hold one DB connection while waiting
// for another pool checkout.
deploymentPrompt := p.resolveDeploymentSystemPrompt(ctx)
// Delegated chats cannot call list_agents or message_agent, so
// strip the root-only orchestration guidance from their prompt.
deploymentPrompt = strings.Replace(deploymentPrompt, subagentOrchestrationPromptBlock, "", 1)
if limitErr := p.checkUsageLimit(ctx, p.db, parent.OwnerID, uuid.NullUUID{UUID: parent.OrganizationID, Valid: true}); limitErr != nil {
return database.Chat{}, limitErr
@@ -1183,7 +1319,7 @@ func (p *Server) awaitSubagentCompletion(
case <-notifyCh:
case <-ticker.C:
case <-timer.C:
return database.Chat{}, "", xerrors.New("timed out waiting for delegated subagent completion")
return database.Chat{}, "", ErrSubagentWaitTimeout
case <-ctx.Done():
return database.Chat{}, "", ctx.Err()
}
@@ -1199,7 +1335,9 @@ func (p *Server) awaitSubagentCompletion(
}
// handleSubagentDone translates a completed subagent check into the
// appropriate return value, surfacing error-status chats as errors.
// appropriate return value. An error-status chat is returned as a typed
// subagentStatusError that carries the chat and report so the
// wait_agent handler can surface a structured, recoverable-aware payload.
func handleSubagentDone(
chat database.Chat,
report string,
@@ -1209,31 +1347,83 @@ func handleSubagentDone(
if reason == "" {
reason = "agent reached error status"
}
return database.Chat{}, "", xerrors.New(reason)
return database.Chat{}, "", &subagentStatusError{
chat: chat,
report: report,
reason: reason,
}
}
return chat, report, nil
}
func (p *Server) closeSubagent(
// subagentLastErrorMessage extracts the normalized, user-facing message
// from a chat's last_error payload, falling back to the raw JSON when the
// payload is not a recognized ChatError.
func subagentLastErrorMessage(raw pqtype.NullRawMessage) string {
if !raw.Valid {
return ""
}
var payload codersdk.ChatError
if err := json.Unmarshal(raw.RawMessage, &payload); err == nil && payload.Message != "" {
return payload.Message
}
return string(raw.RawMessage)
}
// waitAgentSuccessResponse stops and stores the recording (if active) and
// builds the normal completion payload for a wait_agent call.
func (p *Server) waitAgentSuccessResponse(
ctx context.Context,
recordingID string,
agentConn workspacesdk.AgentConn,
parent database.Chat,
targetChat database.Chat,
report string,
) fantasy.ToolResponse {
var recResult recordingResult
if recordingID != "" && agentConn != nil {
// Use a fresh context for cleanup so a canceled
// parent context does not prevent recording storage.
stopCtx, stopCancel := context.WithTimeout(context.WithoutCancel(ctx), subagentRecordingStopTimeout)
defer stopCancel()
recResult = p.stopAndStoreRecording(stopCtx, agentConn,
recordingID, parent.ID, parent.OwnerID, parent.WorkspaceID)
}
resp := withSubagentType(map[string]any{
"chat_id": targetChat.ID.String(),
"title": targetChat.Title,
"report": report,
"status": string(targetChat.Status),
}, targetChat)
if recResult.recordingFileID != "" {
resp["recording_file_id"] = recResult.recordingFileID
}
if recResult.thumbnailFileID != "" {
resp["thumbnail_file_id"] = recResult.thumbnailFileID
}
return toolJSONResponse(resp)
}
func (p *Server) interruptSubagent(
ctx context.Context,
parentChatID uuid.UUID,
targetChatID uuid.UUID,
) (database.Chat, error) {
) (database.Chat, bool, error) {
isDescendant, err := isSubagentDescendant(ctx, p.db, parentChatID, targetChatID)
if err != nil {
return database.Chat{}, err
return database.Chat{}, false, err
}
if !isDescendant {
return database.Chat{}, ErrSubagentNotDescendant
return database.Chat{}, false, ErrSubagentNotDescendant
}
targetChat, err := p.db.GetChatByID(ctx, targetChatID)
if err != nil {
return database.Chat{}, xerrors.Errorf("get target chat: %w", err)
return database.Chat{}, false, xerrors.Errorf("get target chat: %w", err)
}
if targetChat.Status == database.ChatStatusWaiting {
return targetChat, nil
return targetChat, false, nil
}
updatedChat, err := p.InterruptChat(ctx, targetChat)
@@ -1242,13 +1432,13 @@ func (p *Server) closeSubagent(
// chatstate.Interrupt precondition. Surface the error
// so the caller can decide whether the parent expected
// the subagent to already be waiting.
return database.Chat{}, xerrors.Errorf("interrupt subagent chat: %w", err)
return database.Chat{}, false, xerrors.Errorf("interrupt subagent chat: %w", err)
}
// chatstate.Interrupt lands active runs in `interrupting`
// and requires-action chats in `running`. Workers finalize
// the transition; accept either non-active status as long as
// the transition committed.
return updatedChat, nil
return updatedChat, true, nil
}
func (p *Server) checkSubagentCompletion(
@@ -1260,8 +1450,14 @@ func (p *Server) checkSubagentCompletion(
return database.Chat{}, "", false, xerrors.Errorf("get chat: %w", err)
}
if chat.Status == database.ChatStatusPending || chat.Status == database.ChatStatusRunning {
return database.Chat{}, "", false, nil
// interrupting is transient: the worker transitions it to
// waiting (no queued messages) or running (queued messages).
// Treat it as not-done so the agent settles before
// classification, avoiding stale partial output.
if chat.Status == database.ChatStatusPending ||
chat.Status == database.ChatStatusRunning ||
chat.Status == database.ChatStatusInterrupting {
return chat, "", false, nil
}
report, err := latestSubagentAssistantMessage(ctx, p.db, chatID)
+7 -2
View File
@@ -299,7 +299,12 @@ func buildSpawnAgentDescription(
"subagents modify the same files they will conflict with each other, " +
"so ensure parallel subagent tasks are independent. The child agent " +
"receives the same workspace tools but cannot spawn its own subagents. " +
"After spawning, use wait_agent to collect the result."
"After spawning, use wait_agent to retrieve the result. Agents persist " +
"after completion; reuse an agent via message_agent for follow-up work " +
"when it already has relevant context. Spawned agents are your " +
"responsibility: do not abandon one in a working state (pending or " +
"running); retrieve its result, redirect it with message_agent, or stop " +
"it with interrupt_agent."
if currentChat.PlanMode.Valid && currentChat.PlanMode.ChatPlanMode == database.ChatPlanModePlan {
description += " During plan mode, type=\"" + subagentTypeGeneral +
"\" is for non-mutating substantial investigation and planning support, " +
@@ -340,7 +345,7 @@ func planningOverlaySubagentGuidance() string {
return "Use read_file, execute, process_output, list_templates, read_template, " +
spawnAgentToolName + ", and approved external MCP tools when available to gather context. " +
"Workspace MCP tools are not available in root plan mode, and side-effecting built-in tools such as process_list, process_signal, message_agent, close_agent, and computer-use actions remain unavailable. In Plan Mode, " +
"Workspace MCP tools are not available in root plan mode, and side-effecting built-in tools such as process_list, process_signal, message_agent, interrupt_agent, and computer-use actions remain unavailable. In Plan Mode, " +
spawnAgentToolName + " delegation is for investigation and planning " +
"support, not code writing or implementation. Use type=\"" + subagentTypeGeneral +
"\" for substantial investigation, reasoning, and planning support. " +

Some files were not shown because too many files have changed in this diff Show More