mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
Merge branch 'main' into 06-26-feat_reduce_prerequisites_markdown_font_size
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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)),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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()
|
||||
}()
|
||||
})
|
||||
}
|
||||
}()
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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{
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Generated
+42
-1
@@ -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",
|
||||
|
||||
Generated
+40
-1
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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 := ¬ificationstest.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 := ¬ificationstest.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 := ¬ificationstest.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 := ¬ificationstest.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 := ¬ificationstest.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 := ¬ificationstest.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 := ¬ificationstest.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)
|
||||
|
||||
@@ -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: "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",
|
||||
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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
Generated
+51
-7
@@ -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()
|
||||
|
||||
Generated
+5
-3
@@ -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';
|
||||
Generated
+11
-5
@@ -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.
|
||||
|
||||
Generated
+19
-1
@@ -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)
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Generated
+117
-20
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user