Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d4c7cb2021 | |||
| 25400fedca | |||
| 82bb833099 | |||
| 61beb7bfa8 | |||
| b4be5bcfed | |||
| ceaba0778e | |||
| e24cc5e6da | |||
| 259dee2ea8 | |||
| 8e0516a19c | |||
| 770fdb377c | |||
| 83dbf73dde | |||
| 0ab23abb19 | |||
| c4bf5a2d81 | |||
| 5cb02a6cc0 | |||
| cfdd4a9b88 | |||
| d9159103cd | |||
| 532a1f3054 | |||
| 6aeb144a98 | |||
| f94d8fc019 | |||
| e93a917c2f | |||
| 0f054096e4 | |||
| 2f829286f2 | |||
| 6acfcd5736 | |||
| 9e021f7b57 | |||
| aa306f2262 |
@@ -6,6 +6,8 @@ updates:
|
||||
interval: "weekly"
|
||||
time: "06:00"
|
||||
timezone: "America/Chicago"
|
||||
cooldown:
|
||||
default-days: 7
|
||||
labels: []
|
||||
commit-message:
|
||||
prefix: "ci"
|
||||
@@ -68,8 +70,8 @@ updates:
|
||||
interval: "monthly"
|
||||
time: "06:00"
|
||||
timezone: "America/Chicago"
|
||||
reviewers:
|
||||
- "coder/ts"
|
||||
cooldown:
|
||||
default-days: 7
|
||||
commit-message:
|
||||
prefix: "chore"
|
||||
labels: []
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"ignores": ["PLAN.md"],
|
||||
}
|
||||
@@ -173,6 +173,23 @@ ctx, cancel := context.WithTimeout(ctx, 5*time.Minute)
|
||||
ctx, cancel := context.WithTimeout(ctx, 5*time.Minute)
|
||||
```
|
||||
|
||||
### Avoid Unnecessary Changes
|
||||
|
||||
When fixing a bug or adding a feature, don't modify code unrelated to your
|
||||
task. Unnecessary changes make PRs harder to review and can introduce
|
||||
regressions.
|
||||
|
||||
**Don't reword existing comments or code** unless the change is directly
|
||||
motivated by your task. Rewording comments to be shorter or "cleaner" wastes
|
||||
reviewer time and clutters the diff.
|
||||
|
||||
**Don't delete existing comments** that explain non-obvious behavior. These
|
||||
comments preserve important context about why code works a certain way.
|
||||
|
||||
**When adding tests for new behavior**, add new test cases instead of modifying
|
||||
existing ones. This preserves coverage for the original behavior and makes it
|
||||
clear what the new test covers.
|
||||
|
||||
## Detailed Development Guides
|
||||
|
||||
@.claude/docs/ARCHITECTURE.md
|
||||
|
||||
@@ -1457,6 +1457,8 @@ func (api *API) markDevcontainerDirty(configPath string, modifiedAt time.Time) {
|
||||
|
||||
api.knownDevcontainers[dc.WorkspaceFolder] = dc
|
||||
}
|
||||
|
||||
api.broadcastUpdatesLocked()
|
||||
}
|
||||
|
||||
// cleanupSubAgents removes subagents that are no longer managed by
|
||||
|
||||
@@ -1641,6 +1641,77 @@ func TestAPI(t *testing.T) {
|
||||
require.NotNil(t, response.Devcontainers[0].Container, "container should not be nil")
|
||||
})
|
||||
|
||||
// Verify that modifying a config file broadcasts the dirty status
|
||||
// over websocket immediately.
|
||||
t.Run("FileWatcherDirtyBroadcast", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
configPath := "/workspace/project/.devcontainer/devcontainer.json"
|
||||
fWatcher := newFakeWatcher(t)
|
||||
fLister := &fakeContainerCLI{
|
||||
containers: codersdk.WorkspaceAgentListContainersResponse{
|
||||
Containers: []codersdk.WorkspaceAgentContainer{
|
||||
{
|
||||
ID: "container-id",
|
||||
FriendlyName: "container-name",
|
||||
Running: true,
|
||||
Labels: map[string]string{
|
||||
agentcontainers.DevcontainerLocalFolderLabel: "/workspace/project",
|
||||
agentcontainers.DevcontainerConfigFileLabel: configPath,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
mClock := quartz.NewMock(t)
|
||||
tickerTrap := mClock.Trap().TickerFunc("updaterLoop")
|
||||
|
||||
api := agentcontainers.NewAPI(
|
||||
slogtest.Make(t, nil).Leveled(slog.LevelDebug),
|
||||
agentcontainers.WithContainerCLI(fLister),
|
||||
agentcontainers.WithWatcher(fWatcher),
|
||||
agentcontainers.WithClock(mClock),
|
||||
)
|
||||
api.Start()
|
||||
defer api.Close()
|
||||
|
||||
srv := httptest.NewServer(api.Routes())
|
||||
defer srv.Close()
|
||||
|
||||
tickerTrap.MustWait(ctx).MustRelease(ctx)
|
||||
tickerTrap.Close()
|
||||
|
||||
wsConn, resp, err := websocket.Dial(ctx, "ws"+strings.TrimPrefix(srv.URL, "http")+"/watch", nil)
|
||||
require.NoError(t, err)
|
||||
if resp != nil && resp.Body != nil {
|
||||
defer resp.Body.Close()
|
||||
}
|
||||
defer wsConn.Close(websocket.StatusNormalClosure, "")
|
||||
|
||||
// Read and discard initial state.
|
||||
_, _, err = wsConn.Read(ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
fWatcher.waitNext(ctx)
|
||||
fWatcher.sendEventWaitNextCalled(ctx, fsnotify.Event{
|
||||
Name: configPath,
|
||||
Op: fsnotify.Write,
|
||||
})
|
||||
|
||||
// Verify dirty status is broadcast without advancing the clock.
|
||||
_, msg, err := wsConn.Read(ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
var response codersdk.WorkspaceAgentListContainersResponse
|
||||
err = json.Unmarshal(msg, &response)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, response.Devcontainers, 1)
|
||||
assert.True(t, response.Devcontainers[0].Dirty,
|
||||
"devcontainer should be marked as dirty after config file modification")
|
||||
})
|
||||
|
||||
t.Run("SubAgentLifecycle", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
+1
-7
@@ -152,13 +152,7 @@ func Agent(ctx context.Context, writer io.Writer, agentID uuid.UUID, opts AgentO
|
||||
sw.Log(time.Time{}, codersdk.LogLevelInfo, "==> ℹ︎ To connect immediately, reconnect with --wait=no or CODER_SSH_WAIT=no, see --help for more information.")
|
||||
}
|
||||
|
||||
err = func() error {
|
||||
// In non-blocking mode, skip streaming logs.
|
||||
// See: https://github.com/coder/coder/issues/13580
|
||||
if !opts.Wait {
|
||||
return nil
|
||||
}
|
||||
|
||||
err = func() error { // Use func because of defer in for loop.
|
||||
logStream, logsCloser, err := opts.FetchLogs(ctx, agent.ID, 0, follow)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("fetch workspace agent startup logs: %w", err)
|
||||
|
||||
@@ -90,7 +90,6 @@ func TestExpRpty(t *testing.T) {
|
||||
wantLabel := "coder.devcontainers.TestExpRpty.Container"
|
||||
|
||||
client, workspace, agentToken := setupWorkspaceForAgent(t)
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
pool, err := dockertest.NewPool("")
|
||||
require.NoError(t, err, "Could not connect to docker")
|
||||
ct, err := pool.RunWithOptions(&dockertest.RunOptions{
|
||||
@@ -128,14 +127,15 @@ func TestExpRpty(t *testing.T) {
|
||||
clitest.SetupConfig(t, client, root)
|
||||
pty := ptytest.New(t).Attach(inv)
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
cmdDone := tGo(t, func() {
|
||||
err := inv.WithContext(ctx).Run()
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
|
||||
pty.ExpectMatch(" #")
|
||||
pty.ExpectMatchContext(ctx, " #")
|
||||
pty.WriteLine("hostname")
|
||||
pty.ExpectMatch(ct.Container.Config.Hostname)
|
||||
pty.ExpectMatchContext(ctx, ct.Container.Config.Hostname)
|
||||
pty.WriteLine("exit")
|
||||
<-cmdDone
|
||||
})
|
||||
|
||||
+3
-3
@@ -2052,7 +2052,6 @@ func TestSSH_Container(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client, workspace, agentToken := setupWorkspaceForAgent(t)
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
pool, err := dockertest.NewPool("")
|
||||
require.NoError(t, err, "Could not connect to docker")
|
||||
ct, err := pool.RunWithOptions(&dockertest.RunOptions{
|
||||
@@ -2087,14 +2086,15 @@ func TestSSH_Container(t *testing.T) {
|
||||
clitest.SetupConfig(t, client, root)
|
||||
ptty := ptytest.New(t).Attach(inv)
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
cmdDone := tGo(t, func() {
|
||||
err := inv.WithContext(ctx).Run()
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
|
||||
ptty.ExpectMatch(" #")
|
||||
ptty.ExpectMatchContext(ctx, " #")
|
||||
ptty.WriteLine("hostname")
|
||||
ptty.ExpectMatch(ct.Container.Config.Hostname)
|
||||
ptty.ExpectMatchContext(ctx, ct.Container.Config.Hostname)
|
||||
ptty.WriteLine("exit")
|
||||
<-cmdDone
|
||||
})
|
||||
|
||||
+11
@@ -118,12 +118,23 @@ AI BRIDGE OPTIONS:
|
||||
requests (requires the "oauth2" and "mcp-server-http" experiments to
|
||||
be enabled).
|
||||
|
||||
--aibridge-max-concurrency int, $CODER_AIBRIDGE_MAX_CONCURRENCY (default: 0)
|
||||
Maximum number of concurrent AI Bridge requests. Set to 0 to disable
|
||||
(unlimited).
|
||||
|
||||
--aibridge-openai-base-url string, $CODER_AIBRIDGE_OPENAI_BASE_URL (default: https://api.openai.com/v1/)
|
||||
The base URL of the OpenAI API.
|
||||
|
||||
--aibridge-openai-key string, $CODER_AIBRIDGE_OPENAI_KEY
|
||||
The key to authenticate against the OpenAI API.
|
||||
|
||||
--aibridge-rate-limit int, $CODER_AIBRIDGE_RATE_LIMIT (default: 0)
|
||||
Maximum number of AI Bridge requests per rate window. Set to 0 to
|
||||
disable rate limiting.
|
||||
|
||||
--aibridge-rate-window duration, $CODER_AIBRIDGE_RATE_WINDOW (default: 1m)
|
||||
Duration of the rate limiting window for AI Bridge requests.
|
||||
|
||||
CLIENT OPTIONS:
|
||||
These options change the behavior of how clients interact with the Coder.
|
||||
Clients include the Coder CLI, Coder Desktop, IDE extensions, and the web UI.
|
||||
|
||||
+11
@@ -742,6 +742,17 @@ aibridge:
|
||||
# (token, prompt, tool use).
|
||||
# (default: 60d, type: duration)
|
||||
retention: 1440h0m0s
|
||||
# Maximum number of concurrent AI Bridge requests. Set to 0 to disable
|
||||
# (unlimited).
|
||||
# (default: 0, type: int)
|
||||
max_concurrency: 0
|
||||
# Maximum number of AI Bridge requests per rate window. Set to 0 to disable rate
|
||||
# limiting.
|
||||
# (default: 0, type: int)
|
||||
rate_limit: 0
|
||||
# Duration of the rate limiting window for AI Bridge requests.
|
||||
# (default: 1m, type: duration)
|
||||
rate_window: 1m0s
|
||||
# Configure data retention policies for various database tables. Retention
|
||||
# policies automatically purge old data to reduce database size and improve
|
||||
# performance. Setting a retention duration to 0 disables automatic purging for
|
||||
|
||||
@@ -1680,8 +1680,8 @@ func TestTasksNotification(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
require.Len(t, workspaceAgent.Apps, 1)
|
||||
require.GreaterOrEqual(t, len(workspaceAgent.Apps[0].Statuses), 1)
|
||||
latestStatusIndex := len(workspaceAgent.Apps[0].Statuses) - 1
|
||||
require.Equal(t, tc.newAppStatus, workspaceAgent.Apps[0].Statuses[latestStatusIndex].State)
|
||||
// Statuses are ordered by created_at DESC, so the first element is the latest.
|
||||
require.Equal(t, tc.newAppStatus, workspaceAgent.Apps[0].Statuses[0].State)
|
||||
|
||||
if tc.isNotificationSent {
|
||||
// Then: A notification is sent to the workspace owner (memberUser)
|
||||
|
||||
Generated
+12
-2
@@ -1800,7 +1800,7 @@ const docTemplate = `{
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Organizations"
|
||||
"Enterprise"
|
||||
],
|
||||
"summary": "Add new license",
|
||||
"operationId": "add-new-license",
|
||||
@@ -1836,7 +1836,7 @@ const docTemplate = `{
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Organizations"
|
||||
"Enterprise"
|
||||
],
|
||||
"summary": "Update license entitlements",
|
||||
"operationId": "update-license-entitlements",
|
||||
@@ -11877,9 +11877,19 @@ const docTemplate = `{
|
||||
"inject_coder_mcp_tools": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"max_concurrency": {
|
||||
"description": "Overload protection settings.",
|
||||
"type": "integer"
|
||||
},
|
||||
"openai": {
|
||||
"$ref": "#/definitions/codersdk.AIBridgeOpenAIConfig"
|
||||
},
|
||||
"rate_limit": {
|
||||
"type": "integer"
|
||||
},
|
||||
"rate_window": {
|
||||
"type": "integer"
|
||||
},
|
||||
"retention": {
|
||||
"type": "integer"
|
||||
}
|
||||
|
||||
Generated
+12
-2
@@ -1570,7 +1570,7 @@
|
||||
],
|
||||
"consumes": ["application/json"],
|
||||
"produces": ["application/json"],
|
||||
"tags": ["Organizations"],
|
||||
"tags": ["Enterprise"],
|
||||
"summary": "Add new license",
|
||||
"operationId": "add-new-license",
|
||||
"parameters": [
|
||||
@@ -1602,7 +1602,7 @@
|
||||
}
|
||||
],
|
||||
"produces": ["application/json"],
|
||||
"tags": ["Organizations"],
|
||||
"tags": ["Enterprise"],
|
||||
"summary": "Update license entitlements",
|
||||
"operationId": "update-license-entitlements",
|
||||
"responses": {
|
||||
@@ -10543,9 +10543,19 @@
|
||||
"inject_coder_mcp_tools": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"max_concurrency": {
|
||||
"description": "Overload protection settings.",
|
||||
"type": "integer"
|
||||
},
|
||||
"openai": {
|
||||
"$ref": "#/definitions/codersdk.AIBridgeOpenAIConfig"
|
||||
},
|
||||
"rate_limit": {
|
||||
"type": "integer"
|
||||
},
|
||||
"rate_window": {
|
||||
"type": "integer"
|
||||
},
|
||||
"retention": {
|
||||
"type": "integer"
|
||||
}
|
||||
|
||||
Generated
+2
@@ -3449,6 +3449,8 @@ COMMENT ON INDEX workspace_app_audit_sessions_unique_index IS 'Unique index to e
|
||||
|
||||
CREATE INDEX workspace_app_stats_workspace_id_idx ON workspace_app_stats USING btree (workspace_id);
|
||||
|
||||
CREATE INDEX workspace_app_statuses_app_id_idx ON workspace_app_statuses USING btree (app_id, created_at DESC);
|
||||
|
||||
CREATE INDEX workspace_modules_created_at_idx ON workspace_modules USING btree (created_at);
|
||||
|
||||
CREATE INDEX workspace_next_start_at_idx ON workspaces USING btree (next_start_at) WHERE (deleted = false);
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
DROP INDEX IF EXISTS workspace_app_statuses_app_id_idx;
|
||||
@@ -0,0 +1 @@
|
||||
CREATE INDEX workspace_app_statuses_app_id_idx ON workspace_app_statuses (app_id, created_at DESC);
|
||||
@@ -20246,6 +20246,7 @@ func (q *sqlQuerier) GetWorkspaceAppByAgentIDAndSlug(ctx context.Context, arg Ge
|
||||
|
||||
const getWorkspaceAppStatusesByAppIDs = `-- name: GetWorkspaceAppStatusesByAppIDs :many
|
||||
SELECT id, created_at, agent_id, app_id, workspace_id, state, message, uri FROM workspace_app_statuses WHERE app_id = ANY($1 :: uuid [ ])
|
||||
ORDER BY created_at DESC, id DESC
|
||||
`
|
||||
|
||||
func (q *sqlQuerier) GetWorkspaceAppStatusesByAppIDs(ctx context.Context, ids []uuid.UUID) ([]WorkspaceAppStatus, error) {
|
||||
|
||||
@@ -71,7 +71,8 @@ VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
RETURNING *;
|
||||
|
||||
-- name: GetWorkspaceAppStatusesByAppIDs :many
|
||||
SELECT * FROM workspace_app_statuses WHERE app_id = ANY(@ids :: uuid [ ]);
|
||||
SELECT * FROM workspace_app_statuses WHERE app_id = ANY(@ids :: uuid [ ])
|
||||
ORDER BY created_at DESC, id DESC;
|
||||
|
||||
-- name: GetLatestWorkspaceAppStatusByAppID :one
|
||||
SELECT *
|
||||
|
||||
@@ -87,7 +87,9 @@ func (c *Cache) refreshTemplateBuildTimes(ctx context.Context) error {
|
||||
//nolint:gocritic // This is a system service.
|
||||
ctx = dbauthz.AsSystemRestricted(ctx)
|
||||
|
||||
templates, err := c.database.GetTemplates(ctx)
|
||||
templates, err := c.database.GetTemplatesWithFilter(ctx, database.GetTemplatesWithFilterParams{
|
||||
Deleted: false,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -2598,6 +2598,13 @@ func convertWorkspace(
|
||||
failingAgents := []uuid.UUID{}
|
||||
for _, resource := range workspaceBuild.Resources {
|
||||
for _, agent := range resource.Agents {
|
||||
// Sub-agents (e.g., devcontainer agents) are excluded from the
|
||||
// workspace health calculation. Their health is managed by
|
||||
// their parent agent, and temporary disconnections during
|
||||
// devcontainer rebuilds should not affect workspace health.
|
||||
if agent.ParentID.Valid {
|
||||
continue
|
||||
}
|
||||
if !agent.Health.Healthy {
|
||||
failingAgents = append(failingAgents, agent.ID)
|
||||
}
|
||||
|
||||
@@ -346,6 +346,81 @@ func TestWorkspace(t *testing.T) {
|
||||
assert.False(t, agent2.Health.Healthy)
|
||||
assert.NotEmpty(t, agent2.Health.Reason)
|
||||
})
|
||||
|
||||
t.Run("Sub-agent excluded", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
// This test verifies that sub-agents (e.g., devcontainer agents)
|
||||
// are excluded from the workspace health calculation. When a
|
||||
// devcontainer is rebuilding, the sub-agent may be temporarily
|
||||
// disconnected, but this should not make the workspace unhealthy.
|
||||
client, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
|
||||
Parse: echo.ParseComplete,
|
||||
ProvisionApply: []*proto.Response{{
|
||||
Type: &proto.Response_Apply{
|
||||
Apply: &proto.ApplyComplete{
|
||||
Resources: []*proto.Resource{{
|
||||
Name: "some",
|
||||
Type: "example",
|
||||
Agents: []*proto.Agent{{
|
||||
Id: uuid.NewString(),
|
||||
Name: "parent",
|
||||
Auth: &proto.Agent_Token{},
|
||||
}},
|
||||
}},
|
||||
},
|
||||
},
|
||||
}},
|
||||
})
|
||||
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
|
||||
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
|
||||
workspace := coderdtest.CreateWorkspace(t, client, template.ID)
|
||||
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
// Get the workspace and parent agent.
|
||||
workspace, err := client.Workspace(ctx, workspace.ID)
|
||||
require.NoError(t, err)
|
||||
parentAgent := workspace.LatestBuild.Resources[0].Agents[0]
|
||||
require.True(t, parentAgent.Health.Healthy, "parent agent should be healthy initially")
|
||||
|
||||
// Create a sub-agent with a short connection timeout so it becomes
|
||||
// unhealthy quickly (simulating a devcontainer rebuild scenario).
|
||||
subAgent := dbgen.WorkspaceAgent(t, db, database.WorkspaceAgent{
|
||||
ParentID: uuid.NullUUID{Valid: true, UUID: parentAgent.ID},
|
||||
ResourceID: parentAgent.ResourceID,
|
||||
Name: "subagent",
|
||||
ConnectionTimeoutSeconds: 1,
|
||||
})
|
||||
|
||||
// Wait for the sub-agent to become unhealthy due to timeout.
|
||||
var subAgentUnhealthy bool
|
||||
require.Eventually(t, func() bool {
|
||||
workspace, err = client.Workspace(ctx, workspace.ID)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
for _, res := range workspace.LatestBuild.Resources {
|
||||
for _, agent := range res.Agents {
|
||||
if agent.ID == subAgent.ID && !agent.Health.Healthy {
|
||||
subAgentUnhealthy = true
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}, testutil.WaitShort, testutil.IntervalFast, "sub-agent should become unhealthy")
|
||||
|
||||
require.True(t, subAgentUnhealthy, "sub-agent should be unhealthy")
|
||||
|
||||
// Verify that the workspace is still healthy because sub-agents
|
||||
// are excluded from the health calculation.
|
||||
assert.True(t, workspace.Health.Healthy, "workspace should be healthy despite unhealthy sub-agent")
|
||||
assert.Empty(t, workspace.Health.FailingAgents, "failing agents should not include sub-agent")
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("Archived", func(t *testing.T) {
|
||||
|
||||
@@ -3391,6 +3391,37 @@ Write out the current server config as YAML to stdout.`,
|
||||
YAML: "retention",
|
||||
Annotations: serpent.Annotations{}.Mark(annotationFormatDuration, "true"),
|
||||
},
|
||||
{
|
||||
Name: "AI Bridge Max Concurrency",
|
||||
Description: "Maximum number of concurrent AI Bridge requests. Set to 0 to disable (unlimited).",
|
||||
Flag: "aibridge-max-concurrency",
|
||||
Env: "CODER_AIBRIDGE_MAX_CONCURRENCY",
|
||||
Value: &c.AI.BridgeConfig.MaxConcurrency,
|
||||
Default: "0",
|
||||
Group: &deploymentGroupAIBridge,
|
||||
YAML: "max_concurrency",
|
||||
},
|
||||
{
|
||||
Name: "AI Bridge Rate Limit",
|
||||
Description: "Maximum number of AI Bridge requests per rate window. Set to 0 to disable rate limiting.",
|
||||
Flag: "aibridge-rate-limit",
|
||||
Env: "CODER_AIBRIDGE_RATE_LIMIT",
|
||||
Value: &c.AI.BridgeConfig.RateLimit,
|
||||
Default: "0",
|
||||
Group: &deploymentGroupAIBridge,
|
||||
YAML: "rate_limit",
|
||||
},
|
||||
{
|
||||
Name: "AI Bridge Rate Window",
|
||||
Description: "Duration of the rate limiting window for AI Bridge requests.",
|
||||
Flag: "aibridge-rate-window",
|
||||
Env: "CODER_AIBRIDGE_RATE_WINDOW",
|
||||
Value: &c.AI.BridgeConfig.RateWindow,
|
||||
Default: "1m",
|
||||
Group: &deploymentGroupAIBridge,
|
||||
YAML: "rate_window",
|
||||
Annotations: serpent.Annotations{}.Mark(annotationFormatDuration, "true"),
|
||||
},
|
||||
// Retention settings
|
||||
{
|
||||
Name: "Audit Logs Retention",
|
||||
@@ -3461,6 +3492,10 @@ type AIBridgeConfig struct {
|
||||
Bedrock AIBridgeBedrockConfig `json:"bedrock" typescript:",notnull"`
|
||||
InjectCoderMCPTools serpent.Bool `json:"inject_coder_mcp_tools" typescript:",notnull"`
|
||||
Retention serpent.Duration `json:"retention" typescript:",notnull"`
|
||||
// Overload protection settings.
|
||||
MaxConcurrency serpent.Int64 `json:"max_concurrency" typescript:",notnull"`
|
||||
RateLimit serpent.Int64 `json:"rate_limit" typescript:",notnull"`
|
||||
RateWindow serpent.Duration `json:"rate_window" typescript:",notnull"`
|
||||
}
|
||||
|
||||
type AIBridgeOpenAIConfig struct {
|
||||
|
||||
@@ -4,3 +4,10 @@
|
||||
# Redirect old offline anchor fragments to new airgap anchors
|
||||
/install/offline#offline-docs /install/airgap#airgap-docs 301
|
||||
/install/offline#offline-container-images /install/airgap#airgap-container-images 301
|
||||
|
||||
# Redirect old devcontainers folder to envbuilder
|
||||
/admin/templates/managing-templates/devcontainers /admin/templates/managing-templates/envbuilder 301
|
||||
/admin/templates/managing-templates/devcontainers/index /admin/templates/managing-templates/envbuilder 301
|
||||
/admin/templates/managing-templates/devcontainers/add-devcontainer /admin/templates/managing-templates/envbuilder/add-envbuilder 301
|
||||
/admin/templates/managing-templates/devcontainers/devcontainer-security-caching /admin/templates/managing-templates/envbuilder/envbuilder-security-caching 301
|
||||
/admin/templates/managing-templates/devcontainers/devcontainer-releases-known-issues /admin/templates/managing-templates/envbuilder/envbuilder-releases-known-issues 301
|
||||
|
||||
@@ -510,9 +510,9 @@ resource "kubernetes_pod" "workspace" {
|
||||
## Get help
|
||||
|
||||
- **Examples**: Review real-world examples from the [official Coder templates](https://registry.coder.com/contributors/coder?tab=templates):
|
||||
- [AWS EC2 (Devcontainer)](https://registry.coder.com/templates/aws-devcontainer) - AWS EC2 VMs with devcontainer support
|
||||
- [Docker (Devcontainer)](https://registry.coder.com/templates/docker-devcontainer) - Envbuilder containers with dev container support
|
||||
- [Kubernetes (Devcontainer)](https://registry.coder.com/templates/kubernetes-devcontainer) - Envbuilder pods on Kubernetes
|
||||
- [AWS EC2 (Devcontainer)](https://registry.coder.com/templates/aws-devcontainer) - AWS EC2 VMs with Envbuilder
|
||||
- [Docker (Devcontainer)](https://registry.coder.com/templates/docker-devcontainer) - Docker-in-Docker with Dev Containers integration
|
||||
- [Kubernetes (Devcontainer)](https://registry.coder.com/templates/kubernetes-devcontainer) - Kubernetes pods with Envbuilder
|
||||
- [Docker Containers](https://registry.coder.com/templates/docker) - Basic Docker container workspaces
|
||||
- [AWS EC2 (Linux)](https://registry.coder.com/templates/aws-linux) - AWS EC2 VMs for Linux development
|
||||
- [Google Compute Engine (Linux)](https://registry.coder.com/templates/gcp-vm-container) - GCP VM instances
|
||||
|
||||
+1
-1
@@ -52,7 +52,7 @@ For any information not strictly contained in these sections, check out our
|
||||
### Development containers (dev containers)
|
||||
|
||||
- A
|
||||
[Development Container](./templates/managing-templates/devcontainers/index.md)
|
||||
[Development Container](./templates/extending-templates/devcontainers.md)
|
||||
is an open-source specification for defining development environments (called
|
||||
dev containers). It is generally stored in VCS alongside associated source
|
||||
code. It can reference an existing base image, or a custom Dockerfile that
|
||||
|
||||
@@ -25,7 +25,9 @@ This is the recommended approach for most use cases.
|
||||
|
||||
### Project Discovery
|
||||
|
||||
Enable automatic discovery of Dev Containers in Git repositories. Project discovery automatically scans Git repositories for `.devcontainer/devcontainer.json` or `.devcontainer.json` files and surfaces them in the Coder UI. See the [Environment Variables](#environment-variables) section for detailed configuration options.
|
||||
Alternatively, enable automatic discovery of Dev Containers in Git repositories.
|
||||
The agent scans for `devcontainer.json` files and surfaces them in the Coder UI.
|
||||
See [Environment Variables](#environment-variables) for configuration options.
|
||||
|
||||
## Install the Dev Containers CLI
|
||||
|
||||
@@ -36,7 +38,7 @@ to ensure the `@devcontainers/cli` is installed in your workspace:
|
||||
```terraform
|
||||
module "devcontainers-cli" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "dev.registry.coder.com/modules/devcontainers-cli/coder"
|
||||
source = "registry.coder.com/coder/devcontainers-cli/coder"
|
||||
agent_id = coder_agent.dev.id
|
||||
}
|
||||
```
|
||||
@@ -72,6 +74,10 @@ resource "coder_devcontainer" "my-repository" {
|
||||
> module to ensure your repository is cloned into the workspace folder and ready
|
||||
> for automatic startup.
|
||||
|
||||
For multi-repo workspaces, define multiple `coder_devcontainer` resources, each
|
||||
pointing to a different repository. Each one runs as a separate sub-agent with
|
||||
its own terminal and apps in the dashboard.
|
||||
|
||||
## Enable Dev Containers Integration
|
||||
|
||||
Dev Containers integration is **enabled by default** in Coder 2.24.0 and later.
|
||||
@@ -119,15 +125,17 @@ by setting this to `false`.
|
||||
|
||||
Enables automatic discovery of Dev Containers in Git repositories.
|
||||
|
||||
When enabled, the agent will:
|
||||
When enabled, the agent scans the configured working directory (set via the
|
||||
`directory` attribute in `coder_agent`, typically the user's home directory) for
|
||||
Git repositories. If the directory itself is a Git repository, it searches that
|
||||
project. Otherwise, it searches immediate subdirectories for Git repositories.
|
||||
|
||||
- Scan the agent directory for Git repositories
|
||||
- Look for `.devcontainer/devcontainer.json` or `.devcontainer.json` files
|
||||
- Surface discovered Dev Containers automatically in the Coder UI
|
||||
- Respect `.gitignore` patterns during discovery
|
||||
For each repository found, the agent looks for `devcontainer.json` files in the
|
||||
[standard locations](../../../user-guides/devcontainers/index.md#add-a-devcontainerjson)
|
||||
and surfaces discovered Dev Containers in the Coder UI. Discovery respects
|
||||
`.gitignore` patterns.
|
||||
|
||||
You can disable automatic discovery by setting this to `false` if you prefer to
|
||||
use only the `coder_devcontainer` resource for explicit configuration.
|
||||
Set to `false` if you prefer explicit configuration via `coder_devcontainer`.
|
||||
|
||||
### CODER_AGENT_DEVCONTAINERS_DISCOVERY_AUTOSTART_ENABLE
|
||||
|
||||
@@ -142,67 +150,33 @@ always auto-start regardless of this setting.
|
||||
|
||||
## Per-Container Customizations
|
||||
|
||||
Individual Dev Containers can be customized using the `customizations.coder` block
|
||||
in your `devcontainer.json` file. These customizations allow you to control
|
||||
container-specific behavior without modifying your template.
|
||||
> [!NOTE]
|
||||
>
|
||||
> Dev container sub-agents are created dynamically after workspace provisioning,
|
||||
> so Terraform resources like
|
||||
> [`coder_script`](https://registry.terraform.io/providers/coder/coder/latest/docs/resources/script)
|
||||
> and [`coder_app`](https://registry.terraform.io/providers/coder/coder/latest/docs/resources/app)
|
||||
> cannot currently be attached to them. Modules from the
|
||||
> [Coder registry](https://registry.coder.com) that depend on these resources
|
||||
> are also not currently supported for sub-agents.
|
||||
>
|
||||
> To add tools to dev containers, use
|
||||
> [dev container features](../../../user-guides/devcontainers/working-with-dev-containers.md#dev-container-features).
|
||||
> For Coder-specific apps, use the
|
||||
> [`apps` customization](../../../user-guides/devcontainers/customizing-dev-containers.md#custom-apps).
|
||||
|
||||
### Ignore Specific Containers
|
||||
Developers can customize individual dev containers using the `customizations.coder`
|
||||
block in their `devcontainer.json` file. Available options include:
|
||||
|
||||
Use the `ignore` option to hide a Dev Container from Coder completely:
|
||||
- `ignore` — Hide a dev container from Coder completely
|
||||
- `autoStart` — Control whether the container starts automatically (requires
|
||||
`CODER_AGENT_DEVCONTAINERS_DISCOVERY_AUTOSTART_ENABLE` to be enabled)
|
||||
- `name` — Set a custom agent name
|
||||
- `displayApps` — Control which built-in apps appear
|
||||
- `apps` — Define custom applications
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "My Dev Container",
|
||||
"image": "mcr.microsoft.com/devcontainers/base:ubuntu",
|
||||
"customizations": {
|
||||
"coder": {
|
||||
"ignore": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
When `ignore` is set to `true`:
|
||||
|
||||
- The Dev Container won't appear in the Coder UI
|
||||
- Coder won't manage or monitor the container
|
||||
|
||||
This is useful when you have Dev Containers in your repository that you don't
|
||||
want Coder to manage.
|
||||
|
||||
### Per-Container Auto-Start
|
||||
|
||||
Control whether individual Dev Containers should auto-start using the
|
||||
`autoStart` option:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "My Dev Container",
|
||||
"image": "mcr.microsoft.com/devcontainers/base:ubuntu",
|
||||
"customizations": {
|
||||
"coder": {
|
||||
"autoStart": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Important**: The `autoStart` option only applies when global auto-start is
|
||||
enabled via `CODER_AGENT_DEVCONTAINERS_DISCOVERY_AUTOSTART_ENABLE=true`. If
|
||||
the global setting is disabled, containers won't auto-start regardless of this
|
||||
setting.
|
||||
|
||||
When `autoStart` is set to `true`:
|
||||
|
||||
- The Dev Container automatically builds and starts during workspace
|
||||
initialization
|
||||
- Works on a per-container basis (you can enable it for some containers but not
|
||||
others)
|
||||
|
||||
When `autoStart` is set to `false` or omitted:
|
||||
|
||||
- The Dev Container is discovered and shown in the UI
|
||||
- Users must manually start it via the UI
|
||||
For the full reference, see
|
||||
[Customizing dev containers](../../../user-guides/devcontainers/customizing-dev-containers.md).
|
||||
|
||||
## Complete Template Example
|
||||
|
||||
@@ -232,7 +206,7 @@ resource "coder_agent" "dev" {
|
||||
|
||||
module "devcontainers-cli" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "dev.registry.coder.com/modules/devcontainers-cli/coder"
|
||||
source = "registry.coder.com/coder/devcontainers-cli/coder"
|
||||
agent_id = coder_agent.dev.id
|
||||
}
|
||||
|
||||
@@ -243,9 +217,10 @@ resource "coder_devcontainer" "my-repository" {
|
||||
}
|
||||
```
|
||||
|
||||
### Alternative: Project Discovery Mode
|
||||
### Alternative: Project Discovery with Autostart
|
||||
|
||||
You can enable automatic starting of discovered Dev Containers:
|
||||
By default, discovered containers appear in the dashboard but developers must
|
||||
manually start them. To have them start automatically, enable autostart:
|
||||
|
||||
```terraform
|
||||
resource "docker_container" "workspace" {
|
||||
@@ -261,11 +236,11 @@ resource "docker_container" "workspace" {
|
||||
}
|
||||
```
|
||||
|
||||
With this configuration:
|
||||
With autostart enabled:
|
||||
|
||||
- Project discovery is enabled (default behavior)
|
||||
- Discovered containers are automatically started (via the env var)
|
||||
- The `coder_devcontainer` resource is **not** required
|
||||
- Discovered containers automatically build and start during workspace
|
||||
initialization
|
||||
- The `coder_devcontainer` resource is not required
|
||||
- Developers can work with multiple projects seamlessly
|
||||
|
||||
> [!NOTE]
|
||||
@@ -273,8 +248,16 @@ With this configuration:
|
||||
> When using project discovery, you still need to install the devcontainers CLI
|
||||
> using the module or in your base image.
|
||||
|
||||
## Example Template
|
||||
|
||||
The [Docker (Dev Containers)](https://github.com/coder/coder/tree/main/examples/templates/docker-devcontainer)
|
||||
starter template demonstrates Dev Containers integration using Docker-in-Docker.
|
||||
It includes the `devcontainers-cli` module, `git-clone` module, and the
|
||||
`coder_devcontainer` resource.
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [Dev Containers Integration](../../../user-guides/devcontainers/index.md)
|
||||
- [Customizing Dev Containers](../../../user-guides/devcontainers/customizing-dev-containers.md)
|
||||
- [Working with Dev Containers](../../../user-guides/devcontainers/working-with-dev-containers.md)
|
||||
- [Troubleshooting Dev Containers](../../../user-guides/devcontainers/troubleshooting-dev-containers.md)
|
||||
|
||||
@@ -48,11 +48,10 @@ needs of different teams.
|
||||
|
||||
- [Image management](./managing-templates/image-management.md): Learn how to
|
||||
create and publish images for use within Coder workspaces & templates.
|
||||
- [Dev Container support](./managing-templates/devcontainers/index.md): Enable
|
||||
dev containers to allow teams to bring their own tools into Coder workspaces.
|
||||
- [Early Access Dev Containers](../../user-guides/devcontainers/index.md): Try our
|
||||
new direct devcontainers integration (distinct from Envbuilder-based
|
||||
approach).
|
||||
- [Dev Containers integration](./extending-templates/devcontainers.md): Enable
|
||||
native dev containers support using `@devcontainers/cli` and Docker.
|
||||
- [Envbuilder](./managing-templates/envbuilder/index.md): Alternative approach
|
||||
for environments without Docker access.
|
||||
- [Template hardening](./extending-templates/resource-persistence.md#-bulletproofing):
|
||||
Configure your template to prevent certain resources from being destroyed
|
||||
(e.g. user disks).
|
||||
|
||||
+6
-7
@@ -1,10 +1,9 @@
|
||||
# Add a dev container template to Coder
|
||||
# Add an Envbuilder template
|
||||
|
||||
A Coder administrator adds a dev container-compatible template to Coder
|
||||
(Envbuilder). This allows the template to prompt for the developer for their dev
|
||||
container repository's URL as a
|
||||
[parameter](../../extending-templates/parameters.md) when they create their
|
||||
workspace. Envbuilder clones the repo and builds a container from the
|
||||
A Coder administrator adds an Envbuilder-compatible template to Coder. This
|
||||
allows the template to prompt the developer for their dev container repository's
|
||||
URL as a [parameter](../../extending-templates/parameters.md) when they create
|
||||
their workspace. Envbuilder clones the repo and builds a container from the
|
||||
`devcontainer.json` specified in the repo.
|
||||
|
||||
You can create template files through the Coder dashboard, CLI, or you can
|
||||
@@ -143,4 +142,4 @@ Lifecycle scripts are managed by project developers.
|
||||
|
||||
## Next steps
|
||||
|
||||
- [Dev container security and caching](./devcontainer-security-caching.md)
|
||||
- [Envbuilder security and caching](./envbuilder-security-caching.md)
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
# Dev container releases and known issues
|
||||
# Envbuilder releases and known issues
|
||||
|
||||
## Release channels
|
||||
|
||||
+3
-3
@@ -1,4 +1,4 @@
|
||||
# Dev container security and caching
|
||||
# Envbuilder security and caching
|
||||
|
||||
Ensure Envbuilder can only pull pre-approved images and artifacts by configuring
|
||||
it with your existing HTTP proxies, firewalls, and artifact managers.
|
||||
@@ -26,7 +26,7 @@ of caching:
|
||||
|
||||
- Caches the entire image, skipping the build process completely (except for
|
||||
post-build
|
||||
[lifecycle scripts](./add-devcontainer.md#dev-container-lifecycle-scripts)).
|
||||
[lifecycle scripts](./add-envbuilder.md#dev-container-lifecycle-scripts)).
|
||||
|
||||
Note that caching requires push access to a registry, and may require approval
|
||||
from relevant infrastructure team(s).
|
||||
@@ -62,5 +62,5 @@ You may also wish to consult a
|
||||
|
||||
## Next steps
|
||||
|
||||
- [Dev container releases and known issues](./devcontainer-releases-known-issues.md)
|
||||
- [Envbuilder releases and known issues](./envbuilder-releases-known-issues.md)
|
||||
- [Dotfiles](../../../../user-guides/workspace-dotfiles.md)
|
||||
+15
-6
@@ -1,9 +1,18 @@
|
||||
# Dev containers
|
||||
# Envbuilder
|
||||
|
||||
A Development Container is an
|
||||
[open-source specification](https://containers.dev/implementors/spec/) for
|
||||
defining containerized development environments which are also called
|
||||
development containers (dev containers).
|
||||
Envbuilder is an open-source tool that builds development environments from
|
||||
[dev container](https://containers.dev/implementors/spec/) configuration files.
|
||||
Unlike the [native Dev Containers integration](../../extending-templates/devcontainers.md),
|
||||
Envbuilder transforms the workspace image itself rather than running containers
|
||||
inside the workspace.
|
||||
|
||||
> [!NOTE]
|
||||
>
|
||||
> For most use cases, we recommend the
|
||||
> [native Dev Containers integration](../../extending-templates/devcontainers.md),
|
||||
> which uses the standard `@devcontainers/cli` and Docker. Envbuilder is an
|
||||
> alternative for environments where Docker is not available or for
|
||||
> administrator-controlled dev container workflows.
|
||||
|
||||
Dev containers provide developers with increased autonomy and control over their
|
||||
Coder cloud development environments.
|
||||
@@ -119,4 +128,4 @@ of the Coder control plane and even run within a CI/CD pipeline.
|
||||
|
||||
## Next steps
|
||||
|
||||
- [Add a dev container template](./add-devcontainer.md)
|
||||
- [Add an Envbuilder template](./add-envbuilder.md)
|
||||
@@ -70,4 +70,5 @@ specific tooling for their projects. The [Dev Container](https://containers.dev)
|
||||
specification allows developers to define their projects dependencies within a
|
||||
`devcontainer.json` in their Git repository.
|
||||
|
||||
- [Learn how to integrate Dev Containers with Coder](./devcontainers/index.md)
|
||||
- [Configure a template for Dev Containers](../extending-templates/devcontainers.md) (recommended)
|
||||
- [Learn about Envbuilder](./envbuilder/index.md) (alternative for environments without Docker)
|
||||
|
||||
@@ -96,5 +96,6 @@ coder templates delete <template-name>
|
||||
## Next steps
|
||||
|
||||
- [Image management](./image-management.md)
|
||||
- [Devcontainer templates](./devcontainers/index.md)
|
||||
- [Dev Containers integration](../extending-templates/devcontainers.md) (recommended)
|
||||
- [Envbuilder](./envbuilder/index.md) (alternative for environments without Docker)
|
||||
- [Change management](./change-management.md)
|
||||
|
||||
@@ -60,3 +60,65 @@ needs.
|
||||
|
||||
For configuration options and details, see [Data Retention](./setup.md#data-retention)
|
||||
in the AI Bridge setup guide.
|
||||
|
||||
## Tracing
|
||||
|
||||
AI Bridge supports tracing via [OpenTelemetry](https://opentelemetry.io/),
|
||||
providing visibility into request processing, upstream API calls, and MCP server
|
||||
interactions.
|
||||
|
||||
### Enabling Tracing
|
||||
|
||||
AI Bridge tracing is enabled when tracing is enabled for the Coder server.
|
||||
To enable tracing set `CODER_TRACE_ENABLE` environment variable or
|
||||
[--trace](https://coder.com/docs/reference/cli/server#--trace) CLI flag:
|
||||
|
||||
```sh
|
||||
export CODER_TRACE_ENABLE=true
|
||||
```
|
||||
|
||||
```sh
|
||||
coder server --trace
|
||||
```
|
||||
|
||||
### What is Traced
|
||||
|
||||
AI Bridge creates spans for the following operations:
|
||||
|
||||
| Span Name | Description |
|
||||
|---------------------------------------------|------------------------------------------------------|
|
||||
| `CachedBridgePool.Acquire` | Acquiring a request bridge instance from the pool |
|
||||
| `Intercept` | Top-level span for processing an intercepted request |
|
||||
| `Intercept.CreateInterceptor` | Creating the request interceptor |
|
||||
| `Intercept.ProcessRequest` | Processing the request through the bridge |
|
||||
| `Intercept.ProcessRequest.Upstream` | Forwarding the request to the upstream AI provider |
|
||||
| `Intercept.ProcessRequest.ToolCall` | Executing a tool call requested by the AI model |
|
||||
| `Intercept.RecordInterception` | Recording creating interception record |
|
||||
| `Intercept.RecordPromptUsage` | Recording prompt/message data |
|
||||
| `Intercept.RecordTokenUsage` | Recording token consumption |
|
||||
| `Intercept.RecordToolUsage` | Recording tool/function calls |
|
||||
| `Intercept.RecordInterceptionEnded` | Recording the interception as completed |
|
||||
| `ServerProxyManager.Init` | Initializing MCP server proxy connections |
|
||||
| `StreamableHTTPServerProxy.Init` | Setting up HTTP-based MCP server proxies |
|
||||
| `StreamableHTTPServerProxy.Init.fetchTools` | Fetching available tools from MCP servers |
|
||||
|
||||
Example trace of an interception using Jaeger backend:
|
||||
|
||||

|
||||
|
||||
### Capturing Logs in Traces
|
||||
|
||||
> **Note:** Enabling log capture may generate a large volume of trace events.
|
||||
|
||||
To include log messages as trace events, enable trace log capture
|
||||
by setting `CODER_TRACE_LOGS` environment variable or using
|
||||
[--trace-logs](https://coder.com/docs/reference/cli/server#--trace-logs) flag:
|
||||
|
||||
```sh
|
||||
export CODER_TRACE_ENABLE=true
|
||||
export CODER_TRACE_LOGS=true
|
||||
```
|
||||
|
||||
```sh
|
||||
coder server --trace --trace-logs
|
||||
```
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 412 KiB |
@@ -0,0 +1,69 @@
|
||||
# Upgrading from ESR 2.24 to 2.29
|
||||
|
||||
## Guide Overview
|
||||
|
||||
Coder provides Extended Support Releases (ESR) bianually. This guide walks through upgrading from the initial Coder 2.24 ESR to our new 2.29 ESR. It will summarize key changes, highlight breaking updates, and provide a recommended upgrade process.
|
||||
|
||||
Read more about the ESR release process [here](./index.md#extended-support-release), and how Coder supports it.
|
||||
|
||||
## What's New in Coder 2.29
|
||||
|
||||
### Coder Tasks
|
||||
|
||||
Coder Tasks is an interface for running and interfacing with terminal-based coding agents like Claude Code and Codex, powered by Coder workspaces. Beginning in Coder 2.24, Tasks were introduced as an experimental feature that allowed administrators and developers to run long-lived or automated operations from templates. Over subsequent releases, Tasks matured significantly through UI refinement, improved reliability, and underlying task-status improvements in the server and database layers. By 2.29, Tasks were formally promoted to general availability, with full CLI support, a task-specific UI, and consistent visibility of task states across the dashboard. This transition establishes Tasks as a stable automation and job-execution primitive within Coder—particularly suited for long-running background operations like bug fixes, documentation generation, PR reviews, and testing/QA.For more information, read our documentation [here](https://coder.com/docs/ai-coder/tasks).
|
||||
|
||||
### AI Bridge
|
||||
|
||||
AI Bridge was introduced in 2.26, and is a smart gateway that acts as an intermediary between users' coding agents/IDEs and AI providers like OpenAI and Anthropic. It solves three key problems:
|
||||
|
||||
- Centralized authentication/authorization management (users authenticate via Coder instead of managing individual API tokens)
|
||||
- Auditing and attribution of all AI interactions (whether autonomous or human-initiated)
|
||||
- Secure communication between the Coder control plane and upstream AI APIs
|
||||
|
||||
This is a Premium/Beta feature that intercepts AI traffic to record prompts, token usage, and tool invocations. For more information, read our documentation [here](https://coder.com/docs/ai-coder/ai-bridge).
|
||||
|
||||
### Agent Boundaries
|
||||
|
||||
Agent Boundaries was introduced in 2.27 and is currently in Early Access. Agent Boundaries are process-level firewalls in Coder that restrict and audit what autonomous programs (like AI agents) can access and do within a workspace. They provide network policy enforcement—blocking specific domains and HTTP verbs to prevent data exfiltration—and write logs to the workspace for auditability. Boundaries support any terminal-based agent, including custom ones, and can be easily configured through existing Coder modules like the Claude Code module. For more information, read our documentation [here](https://coder.com/docs/ai-coder/agent-boundary).
|
||||
|
||||
### Performance Enhancements
|
||||
|
||||
Performance, particularly at scale, improved across nearly every system layer. Database queries were optimized, several new indexes were added, and expensive migrations—such as migration 371—were reworked to complete faster on large deployments. Caching was introduced for Terraform installer files and workspace/agent lookups, reducing repeated calls. Notification performance improved through more efficient connection pooling. These changes collectively enable deployments with hundreds or thousands of workspaces to operate more smoothly and with lower resource contention.
|
||||
|
||||
### Server and API Updates
|
||||
|
||||
Core server capabilities expanded significantly across the releases. Prebuild workflows gained timestamp-driven invalidation via last_invalidated_at, expired API keys began being automatically purged, and new API key-scope documentation was introduced to help administrators understand authorization boundaries. New API endpoints were added, including the ability to modify a task prompt or look up tasks by name. Template developers benefited from new Terraform directory-persistence capabilities (opt-in on a per-template basis) and improved `protobuf` configuration metadata.
|
||||
|
||||
### CLI Enhancements
|
||||
|
||||
The CLI gained substantial improvements between the two versions. Most notably, beginning in 2.29, Coder’s CLI now stores session tokens in the operating system keyring by default on macOS and Windows, enhancing credential security and reducing exposure from plaintext token storage. Users who rely on directly accessing the token file can opt out using `--use-keyring=false`. The CLI also introduced cross-platform support for keyring storage, gained support for GA Task commands, and integrated experimental functionality for the new Agent Socket API.
|
||||
|
||||
## Changes to be Aware of
|
||||
|
||||
The following are changes introduced after 2.24.X that might break workflows, or require other manual effort to address:
|
||||
|
||||
| Initial State (2.24 & before) | New State (2.25–2.29) | Change Required |
|
||||
|--------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| Workspace updates occur in place without stopping | Workspace updates now forcibly stop workspaces before updating | Expect downtime during updates; update any scripted update flows that rely on seamless updates. See [`coder update` CLI reference](https://coder.com/docs/reference/cli/update). |
|
||||
| Connection events (SSH, port-forward, browser) logged in Audit Log | Connection events moved to Connection Log; historical entries older than 90 days pruned | Update compliance, audit, or ingestion pipelines to use the new [Connection Log](https://coder.com/docs/admin/monitoring/connection-logs) instead of [Audit Logs](https://coder.com/docs/admin/security/audit-logs) for connection events. |
|
||||
| CLI session tokens stored in plaintext file | CLI session tokens stored in OS keyring (macOS/Windows) | Update scripts, automation, or SSO flows that read/modify the token file, or use `--use-keyring=false`. See [Sessions & API Tokens](https://coder.com/docs/admin/users/sessions-tokens) and [`coder login` CLI reference](https://coder.com/docs/reference/cli/login). |
|
||||
| `task_app_id` field available in `codersdk.WorkspaceBuild` | `task_app_id` removed from `codersdk.WorkspaceBuild` | Migrate integrations to use `Task.WorkspaceAppID` instead. See [REST API reference](https://coder.com/docs/reference/api). |
|
||||
| OIDC session handling more permissive | Sessions expire when access tokens expire (typically 1 hour) unless refresh tokens are configured | Add `offline_access` to `CODER_OIDC_SCOPES` (e.g., `openid,profile,email,offline_access`); Google requires `CODER_OIDC_AUTH_URL_PARAMS='{"access_type":"offline","prompt":"consent"}'`. See [OIDC Refresh Tokens](https://coder.com/docs/admin/users/oidc-auth/refresh-tokens). |
|
||||
| Devcontainer agent selection is random when multiple agents exist | Devcontainer agent selection requires explicit choice | Update automated workflows to explicitly specify agent selection. See [Dev Containers Integration](https://coder.com/docs/user-guides/devcontainers) and [Configure a template for dev containers](https://coder.com/docs/admin/templates/extending-templates/devcontainers). |
|
||||
| Terraform execution uses clean directories per build | Terraform workflows use persistent or cached directories when enabled | Update templates that rely on clean execution directories or per-build isolation. See [External Provisioners](https://coder.com/docs/admin/provisioners) and [Template Dependencies](https://coder.com/docs/admin/templates/managing-templates/dependencies). |
|
||||
| Agent and task lifecycle behaviors more permissive | Agent and task lifecycle behaviors enforce stricter permission checks, readiness gating, and ordering | Review workflows for compatibility with stricter readiness and permission requirements. See [Workspace Lifecycle](https://coder.com/docs/user-guides/workspace-lifecycle) and [Extending Templates](https://coder.com/docs/admin/templates/extending-templates). |
|
||||
|
||||
## Upgrading
|
||||
|
||||
The following are recommendations by the Coder team when performing the upgrade:
|
||||
|
||||
- **Perform the upgrade in a staging environment first:** The cumulative changes between 2.24 and 2.29 introduce new subsystems and lifecycle behaviors, so validating templates, authentication flows, and workspace operations in staging helps avoid production issues
|
||||
- **Audit scripts or tools that rely on the CLI token file:** Since 2.29 uses the OS keyring for session tokens on macOS and Windows, update any tooling that reads the plaintext token file or plan to use `--use-keyring=false`
|
||||
- **Review templates using devcontainers or Terraform:** Explicit agent selection, optional persistent/cached Terraform directories, and updated metadata handling mean template authors should retest builds and startup behavior
|
||||
- **Check and update OIDC provider configuration:** Stricter refresh-token requirements in later releases can cause unexpected logouts or failed CLI authentication if providers are not configured according to updated docs
|
||||
- **Update integrations referencing deprecated API fields:** Code relying on `WorkspaceBuild.task_app_id` must migrate to `Task.WorkspaceAppID`, and any custom integrations built against 2.24 APIs should be validated against the new SDK
|
||||
- **Communicate audit-logging changes to security/compliance teams:** From 2.25 onward, connection events moved into the Connection Log, and older audit entries may be pruned, which can affect SIEM pipelines or compliance workflows
|
||||
- **Validate workspace lifecycle automation:** Since updates now require stopping the workspace first, confirm that automated update jobs, scripts, or scheduled tasks still function correctly in this new model
|
||||
- **Retest agent and task automation built on early experimental features:** Updates to agent readiness, permission checks, and lifecycle ordering may affect workflows developed against 2.24’s looser behaviors
|
||||
- **Monitor workspace, template, and Terraform build performance:** New caching, indexes, and DB optimizations may change build times; observing performance post-upgrade helps catch regressions early
|
||||
- **Prepare user communications around Tasks and UI changes:** Tasks are now GA and more visible in the dashboard, and many UI improvements will be new to users coming from 2.24, so a brief internal announcement can smooth the transition
|
||||
@@ -9,12 +9,14 @@ deployment.
|
||||
|
||||
## Release channels
|
||||
|
||||
We support two release channels:
|
||||
[mainline](https://github.com/coder/coder/releases/tag/v2.29.0) for the bleeding
|
||||
edge version of Coder and
|
||||
[stable](https://github.com/coder/coder/releases/latest) for those with lower
|
||||
tolerance for fault. We field our mainline releases publicly for one month
|
||||
before promoting them to stable. The version prior to stable receives patches
|
||||
We support four release channels:
|
||||
|
||||
- **Mainline:** The bleeding edge version of Coder
|
||||
- **Stable:** N-1 of the mainline release
|
||||
- **Security Support:** N-2 of the mainline release
|
||||
- **Extended Support Release:** Biannually released version of Coder
|
||||
|
||||
We field our mainline releases publicly for one month before promoting them to stable. The security support version, so n-2 from mainline, receives patches
|
||||
only for security issues or CVEs.
|
||||
|
||||
### Mainline releases
|
||||
@@ -37,6 +39,16 @@ only for security issues or CVEs.
|
||||
For more information on feature rollout, see our
|
||||
[feature stages documentation](../releases/feature-stages.md).
|
||||
|
||||
### Extended Support Release
|
||||
|
||||
- Designed for organizations that prioritize long-term stability
|
||||
- Receives only critical bugfixes and security patches
|
||||
- Ideal for regulated environments or large deployments with strict upgrade cycles
|
||||
|
||||
ESR releases will be updated with critical bugfixes and security patches that are available to paying customers. This extended support model provides predictable, long-term maintenance for organizations that require enhanced stability. Because ESR forgoes new features in favor of maintenance and stability, it is best suited for teams with strict upgrade constraints. The latest ESR version is [Coder 2.29](https://github.com/coder/coder/releases/tag/v2.29.0).
|
||||
|
||||
For more information, see the [Coder ESR announcement](https://coder.com/blog/esr) or our [ESR Upgrade Guide](./esr-2.24-2.29-upgrade.md).
|
||||
|
||||
## Installing stable
|
||||
|
||||
When installing Coder, we generally advise specifying the desired version from
|
||||
@@ -55,15 +67,15 @@ pages.
|
||||
## Release schedule
|
||||
<!-- Autogenerated release calendar from scripts/update-release-calendar.sh -->
|
||||
<!-- RELEASE_CALENDAR_START -->
|
||||
| Release name | Release Date | Status | Latest Release |
|
||||
|------------------------------------------------|--------------------|------------------|----------------------------------------------------------------|
|
||||
| [2.24](https://coder.com/changelog/coder-2-24) | July 01, 2025 | Not Supported | [v2.24.4](https://github.com/coder/coder/releases/tag/v2.24.4) |
|
||||
| [2.25](https://coder.com/changelog/coder-2-25) | August 05, 2025 | Not Supported | [v2.25.3](https://github.com/coder/coder/releases/tag/v2.25.3) |
|
||||
| [2.26](https://coder.com/changelog/coder-2-26) | September 03, 2025 | Not Supported | [v2.26.6](https://github.com/coder/coder/releases/tag/v2.26.6) |
|
||||
| [2.27](https://coder.com/changelog/coder-2-27) | October 02, 2025 | Security Support | [v2.27.8](https://github.com/coder/coder/releases/tag/v2.27.8) |
|
||||
| [2.28](https://coder.com/changelog/coder-2-28) | November 04, 2025 | Stable | [v2.28.5](https://github.com/coder/coder/releases/tag/v2.28.5) |
|
||||
| [2.29](https://coder.com/changelog/coder-2-29) | December 02, 2025 | Mainline | [v2.29.0](https://github.com/coder/coder/releases/tag/v2.29.0) |
|
||||
| 2.30 | | Not Released | N/A |
|
||||
| Release name | Release Date | Status | Latest Release |
|
||||
|------------------------------------------------|--------------------|--------------------------|----------------------------------------------------------------|
|
||||
| [2.24](https://coder.com/changelog/coder-2-24) | July 01, 2025 | Extended Support Release | [v2.24.4](https://github.com/coder/coder/releases/tag/v2.24.4) |
|
||||
| [2.25](https://coder.com/changelog/coder-2-25) | August 05, 2025 | Not Supported | [v2.25.3](https://github.com/coder/coder/releases/tag/v2.25.3) |
|
||||
| [2.26](https://coder.com/changelog/coder-2-26) | September 03, 2025 | Not Supported | [v2.26.6](https://github.com/coder/coder/releases/tag/v2.26.6) |
|
||||
| [2.27](https://coder.com/changelog/coder-2-27) | October 02, 2025 | Security Support | [v2.27.8](https://github.com/coder/coder/releases/tag/v2.27.8) |
|
||||
| [2.28](https://coder.com/changelog/coder-2-28) | November 04, 2025 | Stable | [v2.28.5](https://github.com/coder/coder/releases/tag/v2.28.5) |
|
||||
| [2.29](https://coder.com/changelog/coder-2-29) | December 02, 2025 | Mainline + ESR | [v2.29.0](https://github.com/coder/coder/releases/tag/v2.29.0) |
|
||||
| 2.30 | | Not Released | N/A |
|
||||
<!-- RELEASE_CALENDAR_END -->
|
||||
|
||||
> [!TIP]
|
||||
@@ -75,6 +87,6 @@ pages.
|
||||
>
|
||||
> The `preview` image is not intended for production use.
|
||||
|
||||
### A note about January releases
|
||||
### January Releases
|
||||
|
||||
As of January, 2025 we skip the January release each year because most of our engineering team is out for the December holiday period.
|
||||
Releases on the first Tuesday of January **are not guaranteed to occur** because most of our team is out for the December holiday period. That being said, an ad-hoc release might still occur. We advise not relying on a January release, or reaching out to Coder directly to determine if one will be occurring closer to the release date.
|
||||
|
||||
+23
-13
@@ -187,6 +187,11 @@
|
||||
"title": "Feature stages",
|
||||
"description": "Information about pre-GA stages.",
|
||||
"path": "./install/releases/feature-stages.md"
|
||||
},
|
||||
{
|
||||
"title": "Upgrading from ESR 2.24 to 2.29",
|
||||
"description": "Upgrade Guide for ESR Releases",
|
||||
"path": "./install/releases/esr-2.24-2.29-upgrade.md"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -326,6 +331,11 @@
|
||||
"description": "Access dev containers via SSH, your IDE, or web terminal.",
|
||||
"path": "./user-guides/devcontainers/working-with-dev-containers.md"
|
||||
},
|
||||
{
|
||||
"title": "Customizing dev containers",
|
||||
"description": "Configure custom agent names, apps, and display options in devcontainer.json.",
|
||||
"path": "./user-guides/devcontainers/customizing-dev-containers.md"
|
||||
},
|
||||
{
|
||||
"title": "Troubleshooting dev containers",
|
||||
"description": "Diagnose and resolve common issues with dev containers in your Coder workspace.",
|
||||
@@ -522,24 +532,24 @@
|
||||
"path": "./admin/templates/managing-templates/change-management.md"
|
||||
},
|
||||
{
|
||||
"title": "Dev containers",
|
||||
"description": "Learn about using development containers in templates",
|
||||
"path": "./admin/templates/managing-templates/devcontainers/index.md",
|
||||
"title": "Envbuilder",
|
||||
"description": "Build dev containers using Envbuilder for environments without Docker",
|
||||
"path": "./admin/templates/managing-templates/envbuilder/index.md",
|
||||
"children": [
|
||||
{
|
||||
"title": "Add a dev container template",
|
||||
"description": "How to add a dev container template to Coder",
|
||||
"path": "./admin/templates/managing-templates/devcontainers/add-devcontainer.md"
|
||||
"title": "Add an Envbuilder template",
|
||||
"description": "How to add an Envbuilder dev container template to Coder",
|
||||
"path": "./admin/templates/managing-templates/envbuilder/add-envbuilder.md"
|
||||
},
|
||||
{
|
||||
"title": "Dev container security and caching",
|
||||
"description": "Configure dev container authentication and caching",
|
||||
"path": "./admin/templates/managing-templates/devcontainers/devcontainer-security-caching.md"
|
||||
"title": "Envbuilder security and caching",
|
||||
"description": "Configure Envbuilder authentication and caching",
|
||||
"path": "./admin/templates/managing-templates/envbuilder/envbuilder-security-caching.md"
|
||||
},
|
||||
{
|
||||
"title": "Dev container releases and known issues",
|
||||
"description": "Dev container releases and known issues",
|
||||
"path": "./admin/templates/managing-templates/devcontainers/devcontainer-releases-known-issues.md"
|
||||
"title": "Envbuilder releases and known issues",
|
||||
"description": "Envbuilder releases and known issues",
|
||||
"path": "./admin/templates/managing-templates/envbuilder/envbuilder-releases-known-issues.md"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -938,7 +948,7 @@
|
||||
},
|
||||
{
|
||||
"title": "AI Bridge",
|
||||
"description": "Centralized LLM and MCP proxy for platform teams",
|
||||
"description": "AI Gateway for Enterprise Governance \u0026 Observability",
|
||||
"path": "./ai-coder/ai-bridge/index.md",
|
||||
"icon_path": "./images/icons/api.svg",
|
||||
"state": ["premium", "beta"],
|
||||
|
||||
Generated
+87
@@ -727,6 +727,93 @@ Status Code **200**
|
||||
|
||||
To perform this operation, you must be authenticated. [Learn more](authentication.md).
|
||||
|
||||
## Add new license
|
||||
|
||||
### Code samples
|
||||
|
||||
```shell
|
||||
# Example request using curl
|
||||
curl -X POST http://coder-server:8080/api/v2/licenses \
|
||||
-H 'Content-Type: application/json' \
|
||||
-H 'Accept: application/json' \
|
||||
-H 'Coder-Session-Token: API_KEY'
|
||||
```
|
||||
|
||||
`POST /licenses`
|
||||
|
||||
> Body parameter
|
||||
|
||||
```json
|
||||
{
|
||||
"license": "string"
|
||||
}
|
||||
```
|
||||
|
||||
### Parameters
|
||||
|
||||
| Name | In | Type | Required | Description |
|
||||
|--------|------|--------------------------------------------------------------------|----------|---------------------|
|
||||
| `body` | body | [codersdk.AddLicenseRequest](schemas.md#codersdkaddlicenserequest) | true | Add license request |
|
||||
|
||||
### Example responses
|
||||
|
||||
> 201 Response
|
||||
|
||||
```json
|
||||
{
|
||||
"claims": {},
|
||||
"id": 0,
|
||||
"uploaded_at": "2019-08-24T14:15:22Z",
|
||||
"uuid": "095be615-a8ad-4c33-8e9c-c7612fbf6c9f"
|
||||
}
|
||||
```
|
||||
|
||||
### Responses
|
||||
|
||||
| Status | Meaning | Description | Schema |
|
||||
|--------|--------------------------------------------------------------|-------------|------------------------------------------------|
|
||||
| 201 | [Created](https://tools.ietf.org/html/rfc7231#section-6.3.2) | Created | [codersdk.License](schemas.md#codersdklicense) |
|
||||
|
||||
To perform this operation, you must be authenticated. [Learn more](authentication.md).
|
||||
|
||||
## Update license entitlements
|
||||
|
||||
### Code samples
|
||||
|
||||
```shell
|
||||
# Example request using curl
|
||||
curl -X POST http://coder-server:8080/api/v2/licenses/refresh-entitlements \
|
||||
-H 'Accept: application/json' \
|
||||
-H 'Coder-Session-Token: API_KEY'
|
||||
```
|
||||
|
||||
`POST /licenses/refresh-entitlements`
|
||||
|
||||
### Example responses
|
||||
|
||||
> 201 Response
|
||||
|
||||
```json
|
||||
{
|
||||
"detail": "string",
|
||||
"message": "string",
|
||||
"validations": [
|
||||
{
|
||||
"detail": "string",
|
||||
"field": "string"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Responses
|
||||
|
||||
| Status | Meaning | Description | Schema |
|
||||
|--------|--------------------------------------------------------------|-------------|--------------------------------------------------|
|
||||
| 201 | [Created](https://tools.ietf.org/html/rfc7231#section-6.3.2) | Created | [codersdk.Response](schemas.md#codersdkresponse) |
|
||||
|
||||
To perform this operation, you must be authenticated. [Learn more](authentication.md).
|
||||
|
||||
## Delete license
|
||||
|
||||
### Code samples
|
||||
|
||||
Generated
+3
@@ -176,10 +176,13 @@ curl -X GET http://coder-server:8080/api/v2/deployment/config \
|
||||
},
|
||||
"enabled": true,
|
||||
"inject_coder_mcp_tools": true,
|
||||
"max_concurrency": 0,
|
||||
"openai": {
|
||||
"base_url": "string",
|
||||
"key": "string"
|
||||
},
|
||||
"rate_limit": 0,
|
||||
"rate_window": 0,
|
||||
"retention": 0
|
||||
}
|
||||
},
|
||||
|
||||
Generated
-87
@@ -1,92 +1,5 @@
|
||||
# Organizations
|
||||
|
||||
## Add new license
|
||||
|
||||
### Code samples
|
||||
|
||||
```shell
|
||||
# Example request using curl
|
||||
curl -X POST http://coder-server:8080/api/v2/licenses \
|
||||
-H 'Content-Type: application/json' \
|
||||
-H 'Accept: application/json' \
|
||||
-H 'Coder-Session-Token: API_KEY'
|
||||
```
|
||||
|
||||
`POST /licenses`
|
||||
|
||||
> Body parameter
|
||||
|
||||
```json
|
||||
{
|
||||
"license": "string"
|
||||
}
|
||||
```
|
||||
|
||||
### Parameters
|
||||
|
||||
| Name | In | Type | Required | Description |
|
||||
|--------|------|--------------------------------------------------------------------|----------|---------------------|
|
||||
| `body` | body | [codersdk.AddLicenseRequest](schemas.md#codersdkaddlicenserequest) | true | Add license request |
|
||||
|
||||
### Example responses
|
||||
|
||||
> 201 Response
|
||||
|
||||
```json
|
||||
{
|
||||
"claims": {},
|
||||
"id": 0,
|
||||
"uploaded_at": "2019-08-24T14:15:22Z",
|
||||
"uuid": "095be615-a8ad-4c33-8e9c-c7612fbf6c9f"
|
||||
}
|
||||
```
|
||||
|
||||
### Responses
|
||||
|
||||
| Status | Meaning | Description | Schema |
|
||||
|--------|--------------------------------------------------------------|-------------|------------------------------------------------|
|
||||
| 201 | [Created](https://tools.ietf.org/html/rfc7231#section-6.3.2) | Created | [codersdk.License](schemas.md#codersdklicense) |
|
||||
|
||||
To perform this operation, you must be authenticated. [Learn more](authentication.md).
|
||||
|
||||
## Update license entitlements
|
||||
|
||||
### Code samples
|
||||
|
||||
```shell
|
||||
# Example request using curl
|
||||
curl -X POST http://coder-server:8080/api/v2/licenses/refresh-entitlements \
|
||||
-H 'Accept: application/json' \
|
||||
-H 'Coder-Session-Token: API_KEY'
|
||||
```
|
||||
|
||||
`POST /licenses/refresh-entitlements`
|
||||
|
||||
### Example responses
|
||||
|
||||
> 201 Response
|
||||
|
||||
```json
|
||||
{
|
||||
"detail": "string",
|
||||
"message": "string",
|
||||
"validations": [
|
||||
{
|
||||
"detail": "string",
|
||||
"field": "string"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Responses
|
||||
|
||||
| Status | Meaning | Description | Schema |
|
||||
|--------|--------------------------------------------------------------|-------------|--------------------------------------------------|
|
||||
| 201 | [Created](https://tools.ietf.org/html/rfc7231#section-6.3.2) | Created | [codersdk.Response](schemas.md#codersdkresponse) |
|
||||
|
||||
To perform this operation, you must be authenticated. [Learn more](authentication.md).
|
||||
|
||||
## Get organizations
|
||||
|
||||
### Code samples
|
||||
|
||||
Generated
+23
-8
@@ -390,24 +390,30 @@
|
||||
},
|
||||
"enabled": true,
|
||||
"inject_coder_mcp_tools": true,
|
||||
"max_concurrency": 0,
|
||||
"openai": {
|
||||
"base_url": "string",
|
||||
"key": "string"
|
||||
},
|
||||
"rate_limit": 0,
|
||||
"rate_window": 0,
|
||||
"retention": 0
|
||||
}
|
||||
```
|
||||
|
||||
### Properties
|
||||
|
||||
| Name | Type | Required | Restrictions | Description |
|
||||
|--------------------------|----------------------------------------------------------------------|----------|--------------|-------------|
|
||||
| `anthropic` | [codersdk.AIBridgeAnthropicConfig](#codersdkaibridgeanthropicconfig) | false | | |
|
||||
| `bedrock` | [codersdk.AIBridgeBedrockConfig](#codersdkaibridgebedrockconfig) | false | | |
|
||||
| `enabled` | boolean | false | | |
|
||||
| `inject_coder_mcp_tools` | boolean | false | | |
|
||||
| `openai` | [codersdk.AIBridgeOpenAIConfig](#codersdkaibridgeopenaiconfig) | false | | |
|
||||
| `retention` | integer | false | | |
|
||||
| Name | Type | Required | Restrictions | Description |
|
||||
|--------------------------|----------------------------------------------------------------------|----------|--------------|-------------------------------|
|
||||
| `anthropic` | [codersdk.AIBridgeAnthropicConfig](#codersdkaibridgeanthropicconfig) | false | | |
|
||||
| `bedrock` | [codersdk.AIBridgeBedrockConfig](#codersdkaibridgebedrockconfig) | false | | |
|
||||
| `enabled` | boolean | false | | |
|
||||
| `inject_coder_mcp_tools` | boolean | false | | |
|
||||
| `max_concurrency` | integer | false | | Overload protection settings. |
|
||||
| `openai` | [codersdk.AIBridgeOpenAIConfig](#codersdkaibridgeopenaiconfig) | false | | |
|
||||
| `rate_limit` | integer | false | | |
|
||||
| `rate_window` | integer | false | | |
|
||||
| `retention` | integer | false | | |
|
||||
|
||||
## codersdk.AIBridgeInterception
|
||||
|
||||
@@ -700,10 +706,13 @@
|
||||
},
|
||||
"enabled": true,
|
||||
"inject_coder_mcp_tools": true,
|
||||
"max_concurrency": 0,
|
||||
"openai": {
|
||||
"base_url": "string",
|
||||
"key": "string"
|
||||
},
|
||||
"rate_limit": 0,
|
||||
"rate_window": 0,
|
||||
"retention": 0
|
||||
}
|
||||
}
|
||||
@@ -2860,10 +2869,13 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o
|
||||
},
|
||||
"enabled": true,
|
||||
"inject_coder_mcp_tools": true,
|
||||
"max_concurrency": 0,
|
||||
"openai": {
|
||||
"base_url": "string",
|
||||
"key": "string"
|
||||
},
|
||||
"rate_limit": 0,
|
||||
"rate_window": 0,
|
||||
"retention": 0
|
||||
}
|
||||
},
|
||||
@@ -3382,10 +3394,13 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o
|
||||
},
|
||||
"enabled": true,
|
||||
"inject_coder_mcp_tools": true,
|
||||
"max_concurrency": 0,
|
||||
"openai": {
|
||||
"base_url": "string",
|
||||
"key": "string"
|
||||
},
|
||||
"rate_limit": 0,
|
||||
"rate_window": 0,
|
||||
"retention": 0
|
||||
}
|
||||
},
|
||||
|
||||
Generated
+33
@@ -1771,6 +1771,39 @@ Whether to inject Coder's MCP tools into intercepted AI Bridge requests (require
|
||||
|
||||
Length of time to retain data such as interceptions and all related records (token, prompt, tool use).
|
||||
|
||||
### --aibridge-max-concurrency
|
||||
|
||||
| | |
|
||||
|-------------|----------------------------------------------|
|
||||
| Type | <code>int</code> |
|
||||
| Environment | <code>$CODER_AIBRIDGE_MAX_CONCURRENCY</code> |
|
||||
| YAML | <code>aibridge.max_concurrency</code> |
|
||||
| Default | <code>0</code> |
|
||||
|
||||
Maximum number of concurrent AI Bridge requests. Set to 0 to disable (unlimited).
|
||||
|
||||
### --aibridge-rate-limit
|
||||
|
||||
| | |
|
||||
|-------------|-----------------------------------------|
|
||||
| Type | <code>int</code> |
|
||||
| Environment | <code>$CODER_AIBRIDGE_RATE_LIMIT</code> |
|
||||
| YAML | <code>aibridge.rate_limit</code> |
|
||||
| Default | <code>0</code> |
|
||||
|
||||
Maximum number of AI Bridge requests per rate window. Set to 0 to disable rate limiting.
|
||||
|
||||
### --aibridge-rate-window
|
||||
|
||||
| | |
|
||||
|-------------|------------------------------------------|
|
||||
| Type | <code>duration</code> |
|
||||
| Environment | <code>$CODER_AIBRIDGE_RATE_WINDOW</code> |
|
||||
| YAML | <code>aibridge.rate_window</code> |
|
||||
| Default | <code>1m</code> |
|
||||
|
||||
Duration of the rate limiting window for AI Bridge requests.
|
||||
|
||||
### --audit-logs-retention
|
||||
|
||||
| | |
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
# Customizing dev containers
|
||||
|
||||
Coder supports custom configuration in your `devcontainer.json` file through the
|
||||
`customizations.coder` block. These options let you control how Coder interacts
|
||||
with your dev container without requiring template changes.
|
||||
|
||||
## Ignore a dev container
|
||||
|
||||
Use the `ignore` option to hide a dev container from Coder completely:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "My Dev Container",
|
||||
"image": "mcr.microsoft.com/devcontainers/base:ubuntu",
|
||||
"customizations": {
|
||||
"coder": {
|
||||
"ignore": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
When `ignore` is set to `true`:
|
||||
|
||||
- The dev container won't appear in the Coder UI
|
||||
- Coder won't manage or monitor the container
|
||||
|
||||
This is useful for dev containers in your repository that you don't want Coder
|
||||
to manage.
|
||||
|
||||
## Auto-start
|
||||
|
||||
Control whether your dev container should auto-start using the `autoStart`
|
||||
option:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "My Dev Container",
|
||||
"image": "mcr.microsoft.com/devcontainers/base:ubuntu",
|
||||
"customizations": {
|
||||
"coder": {
|
||||
"autoStart": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
When `autoStart` is set to `true`, the dev container automatically builds and
|
||||
starts during workspace initialization.
|
||||
|
||||
When `autoStart` is set to `false` or omitted, the dev container is discovered
|
||||
and shown in the UI, but users must manually start it.
|
||||
|
||||
> [!NOTE]
|
||||
>
|
||||
> The `autoStart` option only takes effect when your template administrator has
|
||||
> enabled [`CODER_AGENT_DEVCONTAINERS_DISCOVERY_AUTOSTART_ENABLE`](../../admin/templates/extending-templates/devcontainers.md#coder_agent_devcontainers_discovery_autostart_enable).
|
||||
> If this setting is disabled at the template level, containers won't auto-start
|
||||
> regardless of this option.
|
||||
|
||||
## Custom agent name
|
||||
|
||||
Each dev container gets an agent name derived from the workspace folder path by
|
||||
default. You can set a custom name using the `name` option:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "My Dev Container",
|
||||
"image": "mcr.microsoft.com/devcontainers/base:ubuntu",
|
||||
"customizations": {
|
||||
"coder": {
|
||||
"name": "my-custom-agent"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The name must contain only lowercase letters, numbers, and hyphens. This name
|
||||
appears in `coder ssh` commands and the dashboard (e.g.,
|
||||
`coder ssh my-workspace.my-custom-agent`).
|
||||
|
||||
## Display apps
|
||||
|
||||
Control which built-in Coder apps appear for your dev container using
|
||||
`displayApps`:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "My Dev Container",
|
||||
"image": "mcr.microsoft.com/devcontainers/base:ubuntu",
|
||||
"customizations": {
|
||||
"coder": {
|
||||
"displayApps": {
|
||||
"web_terminal": true,
|
||||
"ssh_helper": true,
|
||||
"port_forwarding_helper": true,
|
||||
"vscode": true,
|
||||
"vscode_insiders": false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Available display apps:
|
||||
|
||||
| App | Description | Default |
|
||||
|--------------------------|------------------------------|---------|
|
||||
| `web_terminal` | Web-based terminal access | `true` |
|
||||
| `ssh_helper` | SSH connection helper | `true` |
|
||||
| `port_forwarding_helper` | Port forwarding interface | `true` |
|
||||
| `vscode` | VS Code Desktop integration | `true` |
|
||||
| `vscode_insiders` | VS Code Insiders integration | `false` |
|
||||
|
||||
## Custom apps
|
||||
|
||||
Define custom applications for your dev container using the `apps` array:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "My Dev Container",
|
||||
"image": "mcr.microsoft.com/devcontainers/base:ubuntu",
|
||||
"customizations": {
|
||||
"coder": {
|
||||
"apps": [
|
||||
{
|
||||
"slug": "zed",
|
||||
"displayName": "Zed Editor",
|
||||
"url": "zed://ssh/${localEnv:CODER_WORKSPACE_AGENT_NAME}.${localEnv:CODER_WORKSPACE_NAME}.${localEnv:CODER_WORKSPACE_OWNER_NAME}.coder${containerWorkspaceFolder}",
|
||||
"external": true,
|
||||
"icon": "/icon/zed.svg",
|
||||
"order": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This example adds a Zed Editor button that opens the dev container directly in
|
||||
the Zed desktop app via its SSH remote feature.
|
||||
|
||||
Each app supports the following properties:
|
||||
|
||||
| Property | Type | Description |
|
||||
|---------------|---------|---------------------------------------------------------------|
|
||||
| `slug` | string | Unique identifier for the app (required) |
|
||||
| `displayName` | string | Human-readable name shown in the UI |
|
||||
| `url` | string | URL to open (supports variable interpolation) |
|
||||
| `command` | string | Command to run instead of opening a URL |
|
||||
| `icon` | string | Path to an icon (e.g., `/icon/code.svg`) |
|
||||
| `openIn` | string | `"tab"` or `"slim-window"` (default: `"slim-window"`) |
|
||||
| `share` | string | `"owner"`, `"authenticated"`, `"organization"`, or `"public"` |
|
||||
| `external` | boolean | Open as external URL (e.g., for desktop apps) |
|
||||
| `group` | string | Group name for organizing apps in the UI |
|
||||
| `order` | number | Sort order for display |
|
||||
| `hidden` | boolean | Hide the app from the UI |
|
||||
| `subdomain` | boolean | Use subdomain-based access |
|
||||
| `healthCheck` | object | Health check configuration (see below) |
|
||||
|
||||
### Health checks
|
||||
|
||||
Configure health checks to monitor app availability:
|
||||
|
||||
```json
|
||||
{
|
||||
"customizations": {
|
||||
"coder": {
|
||||
"apps": [
|
||||
{
|
||||
"slug": "web-server",
|
||||
"displayName": "Web Server",
|
||||
"url": "http://localhost:8080",
|
||||
"healthCheck": {
|
||||
"url": "http://localhost:8080/healthz",
|
||||
"interval": 5,
|
||||
"threshold": 2
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Health check properties:
|
||||
|
||||
| Property | Type | Description |
|
||||
|-------------|--------|-------------------------------------------------|
|
||||
| `url` | string | URL to check for health status |
|
||||
| `interval` | number | Seconds between health checks |
|
||||
| `threshold` | number | Number of failures before marking app unhealthy |
|
||||
|
||||
## Variable interpolation
|
||||
|
||||
App URLs and other string values support variable interpolation for dynamic
|
||||
configuration.
|
||||
|
||||
### Environment variables
|
||||
|
||||
Use `${localEnv:VAR_NAME}` to reference environment variables, with optional
|
||||
default values:
|
||||
|
||||
```json
|
||||
{
|
||||
"customizations": {
|
||||
"coder": {
|
||||
"apps": [
|
||||
{
|
||||
"slug": "my-app",
|
||||
"url": "http://${localEnv:HOST:127.0.0.1}:${localEnv:PORT:8080}"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Coder-provided variables
|
||||
|
||||
Coder provides these environment variables automatically:
|
||||
|
||||
| Variable | Description |
|
||||
|-------------------------------------|------------------------------------|
|
||||
| `CODER_WORKSPACE_NAME` | Name of the workspace |
|
||||
| `CODER_WORKSPACE_OWNER_NAME` | Username of the workspace owner |
|
||||
| `CODER_WORKSPACE_AGENT_NAME` | Name of the dev container agent |
|
||||
| `CODER_WORKSPACE_PARENT_AGENT_NAME` | Name of the parent workspace agent |
|
||||
| `CODER_URL` | URL of the Coder deployment |
|
||||
| `CONTAINER_ID` | Docker container ID |
|
||||
|
||||
### Dev container variables
|
||||
|
||||
Standard dev container variables are also available:
|
||||
|
||||
| Variable | Description |
|
||||
|-------------------------------|--------------------------------------------|
|
||||
| `${containerWorkspaceFolder}` | Workspace folder path inside the container |
|
||||
| `${localWorkspaceFolder}` | Workspace folder path on the host |
|
||||
|
||||
### Session token
|
||||
|
||||
Use `$SESSION_TOKEN` in external app URLs to include the user's session token:
|
||||
|
||||
```json
|
||||
{
|
||||
"customizations": {
|
||||
"coder": {
|
||||
"apps": [
|
||||
{
|
||||
"slug": "custom-ide",
|
||||
"displayName": "Custom IDE",
|
||||
"url": "custom-ide://open?token=$SESSION_TOKEN&folder=${containerWorkspaceFolder}",
|
||||
"external": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Feature options as environment variables
|
||||
|
||||
When your dev container uses features, Coder exposes feature options as
|
||||
environment variables. The format is `FEATURE_<FEATURE_NAME>_OPTION_<OPTION_NAME>`.
|
||||
|
||||
For example, with this feature configuration:
|
||||
|
||||
```json
|
||||
{
|
||||
"features": {
|
||||
"ghcr.io/coder/devcontainer-features/code-server:1": {
|
||||
"port": 9090
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Coder creates `FEATURE_CODE_SERVER_OPTION_PORT=9090`, which you can reference in
|
||||
your apps:
|
||||
|
||||
```json
|
||||
{
|
||||
"features": {
|
||||
"ghcr.io/coder/devcontainer-features/code-server:1": {
|
||||
"port": 9090
|
||||
}
|
||||
},
|
||||
"customizations": {
|
||||
"coder": {
|
||||
"apps": [
|
||||
{
|
||||
"slug": "code-server",
|
||||
"displayName": "Code Server",
|
||||
"url": "http://localhost:${localEnv:FEATURE_CODE_SERVER_OPTION_PORT:8080}",
|
||||
"icon": "/icon/code.svg"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Next steps
|
||||
|
||||
- [Working with dev containers](./working-with-dev-containers.md) — SSH, IDE
|
||||
integration, and port forwarding
|
||||
- [Troubleshooting dev containers](./troubleshooting-dev-containers.md) —
|
||||
Diagnose common issues
|
||||
@@ -1,87 +1,137 @@
|
||||
# Dev Containers Integration
|
||||
|
||||
The Dev Containers integration enables seamless creation and management of Dev
|
||||
Containers in Coder workspaces. This feature leverages the
|
||||
The Dev Containers integration enables seamless creation and management of dev
|
||||
containers in Coder workspaces. This feature leverages the
|
||||
[`@devcontainers/cli`](https://github.com/devcontainers/cli) and
|
||||
[Docker](https://www.docker.com) to provide a streamlined development
|
||||
experience.
|
||||
|
||||
This implementation is different from the existing
|
||||
[Envbuilder-based Dev Containers](../../admin/templates/managing-templates/devcontainers/index.md)
|
||||
offering.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Coder version 2.24.0 or later
|
||||
- Coder CLI version 2.24.0 or later
|
||||
- **Linux or macOS workspace**, Dev Containers are not supported on Windows
|
||||
- A template with:
|
||||
- Dev Containers integration enabled
|
||||
- A Docker-compatible workspace image
|
||||
- Appropriate permissions to execute Docker commands inside your workspace
|
||||
- Docker available inside your workspace
|
||||
- The `@devcontainers/cli` installed in your workspace
|
||||
|
||||
## How It Works
|
||||
|
||||
The Dev Containers integration utilizes the `devcontainer` command from
|
||||
[`@devcontainers/cli`](https://github.com/devcontainers/cli) to manage Dev
|
||||
Containers within your Coder workspace.
|
||||
This command provides comprehensive functionality for creating, starting, and managing Dev Containers.
|
||||
|
||||
Dev environments are configured through a standard `devcontainer.json` file,
|
||||
which allows for extensive customization of your development setup.
|
||||
|
||||
When a workspace with the Dev Containers integration starts:
|
||||
|
||||
1. The workspace initializes the Docker environment.
|
||||
1. The integration detects repositories with a `.devcontainer` directory or a
|
||||
`devcontainer.json` file.
|
||||
1. The integration builds and starts the Dev Container based on the
|
||||
configuration.
|
||||
1. Your workspace automatically detects the running Dev Container.
|
||||
Dev Containers integration is enabled by default. Your workspace needs Docker
|
||||
(via Docker-in-Docker or a mounted socket) and the devcontainers CLI. Most
|
||||
templates with Dev Containers support include both—see
|
||||
[Configure a template for dev containers](../../admin/templates/extending-templates/devcontainers.md)
|
||||
for setup details.
|
||||
|
||||
## Features
|
||||
|
||||
### Available Now
|
||||
- Automatic dev container detection from repositories
|
||||
- Seamless container startup during workspace initialization
|
||||
- Change detection with outdated status indicator
|
||||
- On-demand container rebuild via dashboard button
|
||||
- Integrated IDE experience with VS Code
|
||||
- Direct SSH access to containers
|
||||
- Automatic port detection
|
||||
|
||||
- Automatic Dev Container detection from repositories
|
||||
- Seamless Dev Container startup during workspace initialization
|
||||
- Dev Container change detection and dirty state indicators
|
||||
- On-demand Dev Container recreation via rebuild button
|
||||
- Integrated IDE experience in Dev Containers with VS Code
|
||||
- Direct service access in Dev Containers
|
||||
- SSH access to Dev Containers
|
||||
- Automatic port detection for container ports
|
||||
## Getting started
|
||||
|
||||
### Add a devcontainer.json
|
||||
|
||||
Add a `devcontainer.json` file to your repository. This file defines your
|
||||
development environment. You can place it in:
|
||||
|
||||
- `.devcontainer/devcontainer.json` (recommended)
|
||||
- `.devcontainer.json` (root of repository)
|
||||
- `.devcontainer/<folder>/devcontainer.json` (for multiple configurations)
|
||||
|
||||
The third option allows monorepos to define multiple dev container
|
||||
configurations in separate sub-folders. See the
|
||||
[Dev Container specification](https://containers.dev/implementors/spec/#devcontainerjson)
|
||||
for details.
|
||||
|
||||
Here's a minimal example:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "My Dev Container",
|
||||
"image": "mcr.microsoft.com/devcontainers/base:ubuntu"
|
||||
}
|
||||
```
|
||||
|
||||
For more configuration options, see the
|
||||
[Dev Container specification](https://containers.dev/).
|
||||
|
||||
### Start your dev container
|
||||
|
||||
Coder automatically discovers dev container configurations in your repositories
|
||||
and displays them in your workspace dashboard. From there, you can start a dev
|
||||
container with a single click.
|
||||
|
||||
If your template administrator has configured automatic startup (via the
|
||||
`coder_devcontainer` Terraform resource or autostart settings), your dev
|
||||
container will build and start automatically when the workspace starts.
|
||||
|
||||
### Connect to your dev container
|
||||
|
||||
Once running, your dev container appears as a sub-agent in your workspace
|
||||
dashboard. You can connect via:
|
||||
|
||||
- **Web terminal** in the Coder dashboard
|
||||
- **SSH** using `coder ssh <workspace>.<agent>`
|
||||
- **VS Code** using the "Open in VS Code Desktop" button
|
||||
|
||||
See [Working with dev containers](./working-with-dev-containers.md) for detailed
|
||||
connection instructions.
|
||||
|
||||
## How it works
|
||||
|
||||
The Dev Containers integration uses the `devcontainer` command from
|
||||
[`@devcontainers/cli`](https://github.com/devcontainers/cli) to manage
|
||||
containers within your Coder workspace.
|
||||
|
||||
When a workspace with Dev Containers integration starts:
|
||||
|
||||
1. The workspace initializes the Docker environment.
|
||||
1. The integration detects repositories with dev container configurations.
|
||||
1. Detected dev containers appear in the Coder dashboard.
|
||||
1. If auto-start is configured (via `coder_devcontainer` or autostart settings),
|
||||
the integration builds and starts the dev container automatically.
|
||||
1. Coder creates a sub-agent for the running container, enabling direct access.
|
||||
|
||||
Without auto-start, users can manually start discovered dev containers from the
|
||||
dashboard.
|
||||
|
||||
### Agent naming
|
||||
|
||||
Each dev container gets its own agent name, derived from the workspace folder
|
||||
path. For example, a dev container with workspace folder `/home/coder/my-app`
|
||||
will have an agent named `my-app`.
|
||||
|
||||
Agent names are sanitized to contain only lowercase alphanumeric characters and
|
||||
hyphens. You can also set a
|
||||
[custom agent name](./customizing-dev-containers.md#custom-agent-name)
|
||||
in your `devcontainer.json`.
|
||||
|
||||
## Limitations
|
||||
|
||||
The Dev Containers integration has the following limitations:
|
||||
- **Linux and macOS only** — Dev Containers are not supported on Windows
|
||||
workspaces
|
||||
- Changes to `devcontainer.json` require manual rebuild using the dashboard
|
||||
button
|
||||
- The `forwardPorts` property in `devcontainer.json` with `host:port` syntax
|
||||
(e.g., `"db:5432"`) for Docker Compose sidecar containers is not yet
|
||||
supported. For single-container dev containers, use `coder port-forward` to
|
||||
access ports directly on the sub-agent.
|
||||
- Some advanced dev container features may have limited support
|
||||
|
||||
- **Not supported on Windows**
|
||||
- Changes to the `devcontainer.json` file require manual container recreation
|
||||
using the rebuild button
|
||||
- Some Dev Container features may not work as expected
|
||||
> [!NOTE]
|
||||
> If your template uses Envbuilder rather than Docker-based dev containers, see
|
||||
> the [Envbuilder documentation](../../admin/templates/managing-templates/envbuilder/index.md).
|
||||
|
||||
## Comparison with Envbuilder-based Dev Containers
|
||||
## Next steps
|
||||
|
||||
| Feature | Dev Containers Integration | Envbuilder Dev Containers |
|
||||
|----------------|----------------------------------------|----------------------------------------------|
|
||||
| Implementation | Direct `@devcontainers/cli` and Docker | Coder's Envbuilder |
|
||||
| Target users | Individual developers | Platform teams and administrators |
|
||||
| Configuration | Standard `devcontainer.json` | Terraform templates with Envbuilder |
|
||||
| Management | User-controlled | Admin-controlled |
|
||||
| Requirements | Docker access in workspace | Compatible with more restricted environments |
|
||||
|
||||
Choose the appropriate solution based on your team's needs and infrastructure
|
||||
constraints. For additional details on Envbuilder's Dev Container support, see
|
||||
the
|
||||
[Envbuilder Dev Container spec support documentation](https://github.com/coder/envbuilder/blob/main/docs/devcontainer-spec-support.md).
|
||||
|
||||
## Next Steps
|
||||
|
||||
- Explore the [Dev Container specification](https://containers.dev/) to learn
|
||||
more about advanced configuration options
|
||||
- Read about [Dev Container features](https://containers.dev/features) to
|
||||
enhance your development environment
|
||||
- Check the
|
||||
[VS Code dev containers documentation](https://code.visualstudio.com/docs/devcontainers/containers)
|
||||
for IDE-specific features
|
||||
- [Working with dev containers](./working-with-dev-containers.md) — SSH, IDE
|
||||
integration, and port forwarding
|
||||
- [Customizing dev containers](./customizing-dev-containers.md) — Custom agent
|
||||
names, apps, and display options
|
||||
- [Troubleshooting dev containers](./troubleshooting-dev-containers.md) —
|
||||
Diagnose common issues
|
||||
- [Dev Container specification](https://containers.dev/) — Advanced
|
||||
configuration options
|
||||
- [Dev Container features](https://containers.dev/features) — Enhance your
|
||||
environment with pre-built tools
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Troubleshooting dev containers
|
||||
|
||||
## Dev Container Not Starting
|
||||
## Dev container not starting
|
||||
|
||||
If your dev container fails to start:
|
||||
|
||||
@@ -10,7 +10,108 @@ If your dev container fails to start:
|
||||
- `/tmp/coder-startup-script.log`
|
||||
- `/tmp/coder-script-[script_id].log`
|
||||
|
||||
1. Verify that Docker is running in your workspace.
|
||||
1. Ensure the `devcontainer.json` file is valid.
|
||||
1. Verify Docker is available in your workspace (see below).
|
||||
1. Ensure the `devcontainer.json` file is valid JSON.
|
||||
1. Check that the repository has been cloned correctly.
|
||||
1. Verify the resource limits in your workspace are sufficient.
|
||||
|
||||
## Docker not available
|
||||
|
||||
Dev containers require Docker, either via a running daemon (Docker-in-Docker) or
|
||||
a mounted socket from the host. Your template determines which approach is used.
|
||||
|
||||
**If using Docker-in-Docker**, check that the daemon is running:
|
||||
|
||||
```console
|
||||
sudo service docker status
|
||||
sudo service docker start # if not running
|
||||
```
|
||||
|
||||
**If using a mounted socket**, verify the socket exists and is accessible:
|
||||
|
||||
```console
|
||||
ls -la /var/run/docker.sock
|
||||
docker ps # test access
|
||||
```
|
||||
|
||||
If you get permission errors, your user may need to be in the `docker` group.
|
||||
|
||||
## Finding your dev container agent
|
||||
|
||||
Use `coder show` to list all agents in your workspace, including dev container
|
||||
sub-agents:
|
||||
|
||||
```console
|
||||
coder show <workspace>
|
||||
```
|
||||
|
||||
The agent name is derived from the workspace folder path. For details on how
|
||||
names are generated, see [Agent naming](./index.md#agent-naming).
|
||||
|
||||
## SSH connection issues
|
||||
|
||||
If `coder ssh <workspace>.<agent>` fails:
|
||||
|
||||
1. Verify the agent name using `coder show <workspace>`.
|
||||
1. Check that the dev container is running:
|
||||
|
||||
```console
|
||||
docker ps
|
||||
```
|
||||
|
||||
1. Check the workspace agent logs for container-related errors:
|
||||
|
||||
```console
|
||||
grep -i container /tmp/coder-agent.log
|
||||
```
|
||||
|
||||
## VS Code connection issues
|
||||
|
||||
VS Code connects to dev containers through the Coder extension. The extension
|
||||
uses the sub-agent information to route connections through the parent workspace
|
||||
agent to the dev container. If VS Code fails to connect:
|
||||
|
||||
1. Ensure you have the latest Coder VS Code extension.
|
||||
1. Verify the dev container is running in the Coder dashboard.
|
||||
1. Check the parent workspace agent is healthy.
|
||||
1. Try restarting the dev container from the dashboard.
|
||||
|
||||
## Dev container features not working
|
||||
|
||||
If features from your `devcontainer.json` aren't being applied:
|
||||
|
||||
1. Rebuild the container to ensure features are installed fresh.
|
||||
1. Check the container build output for feature installation errors.
|
||||
1. Verify the feature reference format is correct:
|
||||
|
||||
```json
|
||||
{
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/node:1": {}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Slow container startup
|
||||
|
||||
If your dev container takes a long time to start:
|
||||
|
||||
1. **Use a pre-built image** instead of building from a Dockerfile. This avoids
|
||||
the image build step, though features and lifecycle scripts still run.
|
||||
1. **Minimize features**. Each feature executes as a separate Docker layer
|
||||
during the image build, which is typically the slowest part. Changing
|
||||
`devcontainer.json` invalidates the layer cache, causing features to
|
||||
reinstall on rebuild.
|
||||
1. **Check lifecycle scripts**. Commands in `postStartCommand` run on every
|
||||
container start. Commands in `postCreateCommand` run once per build, so
|
||||
they execute again after each rebuild.
|
||||
|
||||
## Getting more help
|
||||
|
||||
If you continue to experience issues:
|
||||
|
||||
1. Collect logs from `/tmp/coder-agent.log` (both workspace and container).
|
||||
1. Note the exact error messages.
|
||||
1. Check [Coder GitHub issues](https://github.com/coder/coder/issues) for
|
||||
similar problems.
|
||||
1. Contact your Coder administrator for template-specific issues.
|
||||
|
||||
@@ -5,93 +5,151 @@ visual representation of the running environment:
|
||||
|
||||

|
||||
|
||||
## SSH Access
|
||||
## SSH access
|
||||
|
||||
You can SSH into your dev container directly using the Coder CLI:
|
||||
Each dev container has its own agent name, derived from the workspace folder
|
||||
(e.g., `/home/coder/my-project` becomes `my-project`). You can find agent names
|
||||
in your workspace dashboard, or see
|
||||
[Agent naming](./index.md#agent-naming) for details on how names are generated.
|
||||
|
||||
### Using the Coder CLI
|
||||
|
||||
The simplest way to SSH into a dev container is using `coder ssh` with the
|
||||
workspace and agent name:
|
||||
|
||||
```console
|
||||
coder ssh --container keen_dijkstra my-workspace
|
||||
coder ssh <workspace>.<agent>
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
>
|
||||
> SSH access is not yet compatible with the `coder config-ssh` command for use
|
||||
> with OpenSSH. You would need to manually modify your SSH config to include the
|
||||
> `--container` flag in the `ProxyCommand`.
|
||||
For example, to connect to a dev container with agent name `my-project` in
|
||||
workspace `my-workspace`:
|
||||
|
||||
## Web Terminal Access
|
||||
```console
|
||||
coder ssh my-workspace.my-project
|
||||
```
|
||||
|
||||
To SSH into the main workspace agent instead of the dev container:
|
||||
|
||||
```console
|
||||
coder ssh my-workspace
|
||||
```
|
||||
|
||||
### Using OpenSSH (config-ssh)
|
||||
|
||||
You can also use standard OpenSSH tools after generating SSH config entries with
|
||||
`coder config-ssh`:
|
||||
|
||||
```console
|
||||
coder config-ssh
|
||||
```
|
||||
|
||||
This creates a wildcard SSH host entry that matches all your workspaces and
|
||||
their agents, including dev container sub-agents. You can then connect using:
|
||||
|
||||
```console
|
||||
ssh my-project.my-workspace.me.coder
|
||||
```
|
||||
|
||||
The default hostname suffix is `.coder`. If your organization uses a different
|
||||
suffix, adjust the hostname accordingly. The suffix can be configured via
|
||||
[`coder config-ssh --hostname-suffix`](../../reference/cli/config-ssh.md) or
|
||||
by your deployment administrator.
|
||||
|
||||
This method works with any SSH client, IDE remote extensions, `rsync`, `scp`,
|
||||
and other tools that use SSH.
|
||||
|
||||
## Web terminal access
|
||||
|
||||
Once your workspace and dev container are running, you can use the web terminal
|
||||
in the Coder interface to execute commands directly inside the dev container.
|
||||
|
||||

|
||||
|
||||
## IDE Integration (VS Code)
|
||||
## IDE integration (VS Code)
|
||||
|
||||
You can open your dev container directly in VS Code by:
|
||||
|
||||
1. Selecting "Open in VS Code Desktop" from the Coder web interface
|
||||
2. Using the Coder CLI with the container flag:
|
||||
1. Selecting **Open in VS Code Desktop** from the dev container agent in the
|
||||
Coder web interface.
|
||||
1. Using the Coder CLI:
|
||||
|
||||
```console
|
||||
coder open vscode --container keen_dijkstra my-workspace
|
||||
```
|
||||
```console
|
||||
coder open vscode <workspace>.<agent>
|
||||
```
|
||||
|
||||
While optimized for VS Code, other IDEs with dev containers support may also
|
||||
For example:
|
||||
|
||||
```console
|
||||
coder open vscode my-workspace.my-project
|
||||
```
|
||||
|
||||
VS Code will automatically detect the dev container environment and connect
|
||||
appropriately.
|
||||
|
||||
While optimized for VS Code, other IDEs with dev container support may also
|
||||
work.
|
||||
|
||||
## Port Forwarding
|
||||
## Port forwarding
|
||||
|
||||
During the early access phase, port forwarding is limited to ports defined via
|
||||
Since dev containers run as sub-agents, you can forward ports directly to them
|
||||
using standard Coder port forwarding:
|
||||
|
||||
```console
|
||||
coder port-forward <workspace>.<agent> --tcp 8080
|
||||
```
|
||||
|
||||
For example, to forward port 8080 from a dev container with agent name
|
||||
`my-project`:
|
||||
|
||||
```console
|
||||
coder port-forward my-workspace.my-project --tcp 8080
|
||||
```
|
||||
|
||||
This forwards port 8080 on your local machine directly to port 8080 in the dev
|
||||
container. Coder also automatically detects ports opened inside the container.
|
||||
|
||||
### Exposing ports on the parent workspace
|
||||
|
||||
If you need to expose dev container ports through the parent workspace agent
|
||||
(rather than the sub-agent), you can use the
|
||||
[`appPort`](https://containers.dev/implementors/json_reference/#image-specific)
|
||||
in your `devcontainer.json` file.
|
||||
|
||||
> [!NOTE]
|
||||
>
|
||||
> Support for automatic port forwarding via the `forwardPorts` property in
|
||||
> `devcontainer.json` is planned for a future release.
|
||||
|
||||
For example, with this `devcontainer.json` configuration:
|
||||
property in your `devcontainer.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"appPort": ["8080:8080", "4000:3000"]
|
||||
"appPort": ["8080:8080", "4000:3000"]
|
||||
}
|
||||
```
|
||||
|
||||
You can forward these ports to your local machine using:
|
||||
This maps container ports to the parent workspace, which can then be forwarded
|
||||
using the main workspace agent.
|
||||
|
||||
```console
|
||||
coder port-forward my-workspace --tcp 8080,4000
|
||||
```
|
||||
## Dev container features
|
||||
|
||||
This forwards port 8080 (local) -> 8080 (agent) -> 8080 (dev container) and port
|
||||
4000 (local) -> 4000 (agent) -> 3000 (dev container).
|
||||
|
||||
## Dev Container Features
|
||||
|
||||
You can use standard dev container features in your `devcontainer.json` file.
|
||||
Coder also maintains a
|
||||
You can use standard [dev container features](https://containers.dev/features)
|
||||
in your `devcontainer.json` file. Coder also maintains a
|
||||
[repository of features](https://github.com/coder/devcontainer-features) to
|
||||
enhance your development experience.
|
||||
|
||||
Currently available features include [code-server](https://github.com/coder/devcontainer-features/blob/main/src/code-server).
|
||||
|
||||
To use the code-server feature, add the following to your `devcontainer.json`:
|
||||
For example, the
|
||||
[code-server](https://github.com/coder/devcontainer-features/blob/main/src/code-server)
|
||||
feature from the [Coder features repository](https://github.com/coder/devcontainer-features):
|
||||
|
||||
```json
|
||||
{
|
||||
"features": {
|
||||
"ghcr.io/coder/devcontainer-features/code-server:1": {
|
||||
"port": 13337,
|
||||
"host": "0.0.0.0"
|
||||
}
|
||||
},
|
||||
"appPort": ["13337:13337"]
|
||||
"features": {
|
||||
"ghcr.io/coder/devcontainer-features/code-server:1": {
|
||||
"port": 13337,
|
||||
"host": "0.0.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
>
|
||||
> Remember to include the port in the `appPort` section to ensure proper port
|
||||
> forwarding.
|
||||
## Rebuilding dev containers
|
||||
|
||||
When you modify your `devcontainer.json`, you need to rebuild the container for
|
||||
changes to take effect. Coder detects changes and shows an **Outdated** status
|
||||
next to the dev container.
|
||||
|
||||
Click **Rebuild** to recreate your dev container with the updated configuration.
|
||||
|
||||
@@ -7,7 +7,7 @@ These are intended for end-user flows only. If you are an administrator, please
|
||||
refer to our docs on configuring [templates](../admin/index.md) or the
|
||||
[control plane](../admin/index.md).
|
||||
|
||||
Check out our [early access features](../install/releases/feature-stages.md) for upcoming
|
||||
functionality, including [Dev Containers integration](../user-guides/devcontainers/index.md).
|
||||
Check out [Dev Containers integration](./devcontainers/index.md) for running
|
||||
containerized development environments in your Coder workspace.
|
||||
|
||||
<children></children>
|
||||
|
||||
@@ -104,14 +104,14 @@ data "coder_workspace_owner" "me" {}
|
||||
|
||||
module "slackme" {
|
||||
source = "dev.registry.coder.com/coder/slackme/coder"
|
||||
version = "1.0.32"
|
||||
version = "1.0.33"
|
||||
agent_id = coder_agent.dev.id
|
||||
auth_provider_id = "slack"
|
||||
}
|
||||
|
||||
module "dotfiles" {
|
||||
source = "dev.registry.coder.com/coder/dotfiles/coder"
|
||||
version = "1.2.2"
|
||||
version = "1.2.3"
|
||||
agent_id = coder_agent.dev.id
|
||||
}
|
||||
|
||||
|
||||
@@ -333,7 +333,7 @@ data "coder_parameter" "vscode_channel" {
|
||||
module "slackme" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "dev.registry.coder.com/coder/slackme/coder"
|
||||
version = "1.0.32"
|
||||
version = "1.0.33"
|
||||
agent_id = coder_agent.dev.id
|
||||
auth_provider_id = "slack"
|
||||
}
|
||||
@@ -341,7 +341,7 @@ module "slackme" {
|
||||
module "dotfiles" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "dev.registry.coder.com/coder/dotfiles/coder"
|
||||
version = "1.2.2"
|
||||
version = "1.2.3"
|
||||
agent_id = coder_agent.dev.id
|
||||
}
|
||||
|
||||
@@ -357,7 +357,7 @@ module "git-config" {
|
||||
module "git-clone" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "dev.registry.coder.com/coder/git-clone/coder"
|
||||
version = "1.2.1"
|
||||
version = "1.2.2"
|
||||
agent_id = coder_agent.dev.id
|
||||
url = "https://github.com/coder/coder"
|
||||
base_dir = local.repo_base_dir
|
||||
@@ -373,7 +373,7 @@ module "personalize" {
|
||||
module "mux" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/mux/coder"
|
||||
version = "1.0.2"
|
||||
version = "1.0.4"
|
||||
agent_id = coder_agent.dev.id
|
||||
subdomain = true
|
||||
}
|
||||
@@ -391,7 +391,7 @@ module "code-server" {
|
||||
module "vscode-web" {
|
||||
count = contains(jsondecode(data.coder_parameter.ide_choices.value), "vscode-web") ? data.coder_workspace.me.start_count : 0
|
||||
source = "dev.registry.coder.com/coder/vscode-web/coder"
|
||||
version = "1.4.2"
|
||||
version = "1.4.3"
|
||||
agent_id = coder_agent.dev.id
|
||||
folder = local.repo_dir
|
||||
extensions = ["github.copilot"]
|
||||
@@ -429,7 +429,7 @@ module "coder-login" {
|
||||
module "cursor" {
|
||||
count = contains(jsondecode(data.coder_parameter.ide_choices.value), "cursor") ? data.coder_workspace.me.start_count : 0
|
||||
source = "dev.registry.coder.com/coder/cursor/coder"
|
||||
version = "1.3.3"
|
||||
version = "1.4.0"
|
||||
agent_id = coder_agent.dev.id
|
||||
folder = local.repo_dir
|
||||
}
|
||||
@@ -437,7 +437,7 @@ module "cursor" {
|
||||
module "windsurf" {
|
||||
count = contains(jsondecode(data.coder_parameter.ide_choices.value), "windsurf") ? data.coder_workspace.me.start_count : 0
|
||||
source = "dev.registry.coder.com/coder/windsurf/coder"
|
||||
version = "1.2.1"
|
||||
version = "1.3.0"
|
||||
agent_id = coder_agent.dev.id
|
||||
folder = local.repo_dir
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"cdr.dev/slog"
|
||||
@@ -32,7 +33,11 @@ type Server struct {
|
||||
// A pool of [aibridge.RequestBridge] instances, which service incoming requests.
|
||||
requestBridgePool Pooler
|
||||
|
||||
// overloadProtection provides rate limiting and concurrency control.
|
||||
overloadProtection *OverloadProtection
|
||||
|
||||
logger slog.Logger
|
||||
tracer trace.Tracer
|
||||
wg sync.WaitGroup
|
||||
|
||||
// initConnectionCh will receive when the daemon connects to coderd for the
|
||||
@@ -48,7 +53,7 @@ type Server struct {
|
||||
shutdownOnce sync.Once
|
||||
}
|
||||
|
||||
func New(ctx context.Context, pool Pooler, rpcDialer Dialer, logger slog.Logger) (*Server, error) {
|
||||
func New(ctx context.Context, pool Pooler, rpcDialer Dialer, logger slog.Logger, tracer trace.Tracer, overloadCfg *OverloadConfig) (*Server, error) {
|
||||
if rpcDialer == nil {
|
||||
return nil, xerrors.Errorf("nil rpcDialer given")
|
||||
}
|
||||
@@ -56,6 +61,7 @@ func New(ctx context.Context, pool Pooler, rpcDialer Dialer, logger slog.Logger)
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
daemon := &Server{
|
||||
logger: logger,
|
||||
tracer: tracer,
|
||||
clientDialer: rpcDialer,
|
||||
clientCh: make(chan DRPCClient),
|
||||
lifecycleCtx: ctx,
|
||||
@@ -65,6 +71,16 @@ func New(ctx context.Context, pool Pooler, rpcDialer Dialer, logger slog.Logger)
|
||||
requestBridgePool: pool,
|
||||
}
|
||||
|
||||
// Initialize overload protection if configured.
|
||||
if overloadCfg != nil {
|
||||
daemon.overloadProtection = NewOverloadProtection(*overloadCfg, logger)
|
||||
logger.Info(ctx, "overload protection enabled",
|
||||
slog.F("max_concurrency", overloadCfg.MaxConcurrency),
|
||||
slog.F("rate_limit", overloadCfg.RateLimit),
|
||||
slog.F("rate_window", overloadCfg.RateWindow),
|
||||
)
|
||||
}
|
||||
|
||||
daemon.wg.Add(1)
|
||||
go daemon.connect()
|
||||
|
||||
@@ -143,7 +159,7 @@ func (s *Server) GetRequestHandler(ctx context.Context, req Request) (http.Handl
|
||||
return nil, xerrors.New("nil requestBridgePool")
|
||||
}
|
||||
|
||||
reqBridge, err := s.requestBridgePool.Acquire(ctx, req, s.Client, NewMCPProxyFactory(s.logger, s.Client))
|
||||
reqBridge, err := s.requestBridgePool.Acquire(ctx, req, s.Client, NewMCPProxyFactory(s.logger, s.tracer, s.Client))
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("acquire request bridge: %w", err)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"slices"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -13,8 +14,13 @@ import (
|
||||
promtest "github.com/prometheus/client_golang/prometheus/testutil"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
"go.opentelemetry.io/otel"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
sdktrace "go.opentelemetry.io/otel/sdk/trace"
|
||||
"go.opentelemetry.io/otel/sdk/trace/tracetest"
|
||||
|
||||
"github.com/coder/aibridge"
|
||||
aibtracing "github.com/coder/aibridge/tracing"
|
||||
"github.com/coder/coder/v2/coderd/coderdtest"
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/database/dbauthz"
|
||||
@@ -28,6 +34,8 @@ import (
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
)
|
||||
|
||||
var testTracer = otel.Tracer("aibridged_test")
|
||||
|
||||
// TestIntegration is not an exhaustive test against the upstream AI providers' SDKs (see coder/aibridge for those).
|
||||
// This test validates that:
|
||||
// - intercepted requests can be authenticated/authorized
|
||||
@@ -35,11 +43,17 @@ import (
|
||||
// - responses can be returned as expected
|
||||
// - interceptions are logged, as well as their related prompt, token, and tool calls
|
||||
// - MCP server configurations are returned as expected
|
||||
// - tracing spans are properly recorded
|
||||
func TestIntegration(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
|
||||
sr := tracetest.NewSpanRecorder()
|
||||
tp := sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(sr))
|
||||
tracer := tp.Tracer(t.Name())
|
||||
defer func() { _ = tp.Shutdown(t.Context()) }()
|
||||
|
||||
// Create mock MCP server.
|
||||
var mcpTokenReceived string
|
||||
mockMCPServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -169,13 +183,13 @@ func TestIntegration(t *testing.T) {
|
||||
|
||||
logger := testutil.Logger(t)
|
||||
providers := []aibridge.Provider{aibridge.NewOpenAIProvider(aibridge.OpenAIConfig{BaseURL: mockOpenAI.URL})}
|
||||
pool, err := aibridged.NewCachedBridgePool(aibridged.DefaultPoolOptions, providers, nil, logger)
|
||||
pool, err := aibridged.NewCachedBridgePool(aibridged.DefaultPoolOptions, providers, logger, nil, tracer)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Given: aibridged is started.
|
||||
srv, err := aibridged.New(t.Context(), pool, func(ctx context.Context) (aibridged.DRPCClient, error) {
|
||||
return aiBridgeClient, nil
|
||||
}, logger)
|
||||
}, logger, tracer, nil)
|
||||
require.NoError(t, err, "create new aibridged")
|
||||
t.Cleanup(func() {
|
||||
_ = srv.Shutdown(ctx)
|
||||
@@ -256,6 +270,44 @@ func TestIntegration(t *testing.T) {
|
||||
|
||||
// Then: the MCP server was initialized.
|
||||
require.Contains(t, mcpTokenReceived, authLink.OAuthAccessToken, "mock MCP server not requested")
|
||||
|
||||
// Then: verify tracing spans were recorded.
|
||||
spans := sr.Ended()
|
||||
require.NotEmpty(t, spans)
|
||||
i := slices.IndexFunc(spans, func(s sdktrace.ReadOnlySpan) bool { return s.Name() == "CachedBridgePool.Acquire" })
|
||||
require.NotEqual(t, -1, i, "span named 'CachedBridgePool.Acquire' not found")
|
||||
|
||||
expectAttrs := []attribute.KeyValue{
|
||||
attribute.String(aibtracing.InitiatorID, user.ID.String()),
|
||||
attribute.String(aibtracing.APIKeyID, keyID),
|
||||
}
|
||||
require.Equal(t, spans[i].Attributes(), expectAttrs)
|
||||
|
||||
// Check for aibridge spans.
|
||||
spanNames := make(map[string]bool)
|
||||
for _, span := range spans {
|
||||
spanNames[span.Name()] = true
|
||||
}
|
||||
|
||||
expectedAibridgeSpans := []string{
|
||||
"CachedBridgePool.Acquire",
|
||||
"ServerProxyManager.Init",
|
||||
"StreamableHTTPServerProxy.Init",
|
||||
"StreamableHTTPServerProxy.Init.fetchTools",
|
||||
"Intercept",
|
||||
"Intercept.CreateInterceptor",
|
||||
"Intercept.RecordInterception",
|
||||
"Intercept.ProcessRequest",
|
||||
"Intercept.ProcessRequest.Upstream",
|
||||
"Intercept.RecordPromptUsage",
|
||||
"Intercept.RecordTokenUsage",
|
||||
"Intercept.RecordToolUsage",
|
||||
"Intercept.RecordInterceptionEnded",
|
||||
}
|
||||
|
||||
for _, expectedSpan := range expectedAibridgeSpans {
|
||||
require.Contains(t, spanNames, expectedSpan)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIntegrationWithMetrics validates that Prometheus metrics are correctly incremented
|
||||
@@ -324,13 +376,13 @@ func TestIntegrationWithMetrics(t *testing.T) {
|
||||
providers := []aibridge.Provider{aibridge.NewOpenAIProvider(aibridge.OpenAIConfig{BaseURL: mockOpenAI.URL})}
|
||||
|
||||
// Create pool with metrics.
|
||||
pool, err := aibridged.NewCachedBridgePool(aibridged.DefaultPoolOptions, providers, metrics, logger)
|
||||
pool, err := aibridged.NewCachedBridgePool(aibridged.DefaultPoolOptions, providers, logger, metrics, testTracer)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Given: aibridged is started.
|
||||
srv, err := aibridged.New(ctx, pool, func(ctx context.Context) (aibridged.DRPCClient, error) {
|
||||
return aiBridgeClient, nil
|
||||
}, logger)
|
||||
}, logger, testTracer, nil)
|
||||
require.NoError(t, err, "create new aibridged")
|
||||
t.Cleanup(func() {
|
||||
_ = srv.Shutdown(ctx)
|
||||
|
||||
@@ -41,7 +41,7 @@ func newTestServer(t *testing.T) (*aibridged.Server, *mock.MockDRPCClient, *mock
|
||||
pool,
|
||||
func(ctx context.Context) (aibridged.DRPCClient, error) {
|
||||
return client, nil
|
||||
}, logger)
|
||||
}, logger, testTracer, nil)
|
||||
require.NoError(t, err, "create new aibridged")
|
||||
t.Cleanup(func() {
|
||||
srv.Shutdown(context.Background())
|
||||
@@ -290,7 +290,7 @@ func TestRouting(t *testing.T) {
|
||||
aibridge.NewOpenAIProvider(aibridge.OpenAIConfig{BaseURL: openaiSrv.URL}),
|
||||
aibridge.NewAnthropicProvider(aibridge.AnthropicConfig{BaseURL: antSrv.URL}, nil),
|
||||
}
|
||||
pool, err := aibridged.NewCachedBridgePool(aibridged.DefaultPoolOptions, providers, nil, logger)
|
||||
pool, err := aibridged.NewCachedBridgePool(aibridged.DefaultPoolOptions, providers, logger, nil, testTracer)
|
||||
require.NoError(t, err)
|
||||
conn := &mockDRPCConn{}
|
||||
client.EXPECT().DRPCConn().AnyTimes().Return(conn)
|
||||
@@ -309,7 +309,7 @@ func TestRouting(t *testing.T) {
|
||||
// Given: aibridged is started.
|
||||
srv, err := aibridged.New(t.Context(), pool, func(ctx context.Context) (aibridged.DRPCClient, error) {
|
||||
return client, nil
|
||||
}, logger)
|
||||
}, logger, testTracer, nil)
|
||||
require.NoError(t, err, "create new aibridged")
|
||||
t.Cleanup(func() {
|
||||
_ = srv.Shutdown(testutil.Context(t, testutil.WaitShort))
|
||||
|
||||
@@ -19,8 +19,19 @@ var (
|
||||
ErrConnect = xerrors.New("could not connect to coderd")
|
||||
ErrUnauthorized = xerrors.New("unauthorized")
|
||||
ErrAcquireRequestHandler = xerrors.New("failed to acquire request handler")
|
||||
ErrOverloaded = xerrors.New("server is overloaded")
|
||||
)
|
||||
|
||||
// Handler returns an http.Handler that wraps the server with any configured
|
||||
// overload protection (rate limiting and concurrency control).
|
||||
func (s *Server) Handler() http.Handler {
|
||||
var handler http.Handler = s
|
||||
if s.overloadProtection != nil {
|
||||
handler = s.overloadProtection.WrapHandler(handler)
|
||||
}
|
||||
return handler
|
||||
}
|
||||
|
||||
// ServeHTTP is the entrypoint for requests which will be intercepted by AI Bridge.
|
||||
// This function will validate that the given API key may be used to perform the request.
|
||||
//
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"regexp"
|
||||
"time"
|
||||
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"cdr.dev/slog"
|
||||
@@ -28,30 +29,32 @@ type MCPProxyBuilder interface {
|
||||
// The SessionKey from [Request] is used to authenticate against the Coder MCP server.
|
||||
//
|
||||
// NOTE: the [mcp.ServerProxier] instance may be proxying one or more MCP servers.
|
||||
Build(ctx context.Context, req Request) (mcp.ServerProxier, error)
|
||||
Build(ctx context.Context, req Request, tracer trace.Tracer) (mcp.ServerProxier, error)
|
||||
}
|
||||
|
||||
var _ MCPProxyBuilder = &MCPProxyFactory{}
|
||||
|
||||
type MCPProxyFactory struct {
|
||||
logger slog.Logger
|
||||
tracer trace.Tracer
|
||||
clientFn ClientFunc
|
||||
}
|
||||
|
||||
func NewMCPProxyFactory(logger slog.Logger, clientFn ClientFunc) *MCPProxyFactory {
|
||||
func NewMCPProxyFactory(logger slog.Logger, tracer trace.Tracer, clientFn ClientFunc) *MCPProxyFactory {
|
||||
return &MCPProxyFactory{
|
||||
logger: logger,
|
||||
tracer: tracer,
|
||||
clientFn: clientFn,
|
||||
}
|
||||
}
|
||||
|
||||
func (m *MCPProxyFactory) Build(ctx context.Context, req Request) (mcp.ServerProxier, error) {
|
||||
func (m *MCPProxyFactory) Build(ctx context.Context, req Request, tracer trace.Tracer) (mcp.ServerProxier, error) {
|
||||
proxiers, err := m.retrieveMCPServerConfigs(ctx, req)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("resolve configs: %w", err)
|
||||
}
|
||||
|
||||
return mcp.NewServerProxyManager(proxiers), nil
|
||||
return mcp.NewServerProxyManager(proxiers, tracer), nil
|
||||
}
|
||||
|
||||
func (m *MCPProxyFactory) retrieveMCPServerConfigs(ctx context.Context, req Request) (map[string]mcp.ServerProxier, error) {
|
||||
@@ -173,7 +176,6 @@ func (m *MCPProxyFactory) newStreamableHTTPServerProxy(cfg *proto.MCPServerConfi
|
||||
// The proxy could then use its interface to retrieve a new access token and re-establish a connection.
|
||||
// For now though, the short TTL of this cache should mostly mask this problem.
|
||||
srv, err := mcp.NewStreamableHTTPServerProxy(
|
||||
m.logger.Named(fmt.Sprintf("mcp-server-proxy-%s", cfg.GetId())),
|
||||
cfg.GetId(),
|
||||
cfg.GetUrl(),
|
||||
// See https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization#token-requirements.
|
||||
@@ -182,6 +184,8 @@ func (m *MCPProxyFactory) newStreamableHTTPServerProxy(cfg *proto.MCPServerConfi
|
||||
},
|
||||
allowlist,
|
||||
denylist,
|
||||
m.logger.Named(fmt.Sprintf("mcp-server-proxy-%s", cfg.GetId())),
|
||||
m.tracer,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("create streamable HTTP MCP server proxy: %w", err)
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.opentelemetry.io/otel"
|
||||
|
||||
"github.com/coder/coder/v2/enterprise/aibridged/proto"
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
@@ -42,7 +43,7 @@ func TestMCPRegex(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := testutil.Logger(t)
|
||||
f := NewMCPProxyFactory(logger, nil)
|
||||
f := NewMCPProxyFactory(logger, otel.Tracer("aibridged_test"), nil)
|
||||
|
||||
_, err := f.newStreamableHTTPServerProxy(&proto.MCPServerConfig{
|
||||
Id: "mock",
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
package aibridged
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/httprate"
|
||||
|
||||
"cdr.dev/slog"
|
||||
"github.com/coder/coder/v2/coderd/httpapi"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
)
|
||||
|
||||
// OverloadConfig configures overload protection for the AI Bridge server.
|
||||
type OverloadConfig struct {
|
||||
// MaxConcurrency is the maximum number of concurrent requests allowed.
|
||||
// Set to 0 to disable concurrency limiting.
|
||||
MaxConcurrency int64
|
||||
|
||||
// RateLimit is the maximum number of requests per RateWindow.
|
||||
// Set to 0 to disable rate limiting.
|
||||
RateLimit int64
|
||||
|
||||
// RateWindow is the duration of the rate limiting window.
|
||||
RateWindow time.Duration
|
||||
}
|
||||
|
||||
// OverloadProtection provides middleware for protecting the AI Bridge server
|
||||
// from overload conditions.
|
||||
type OverloadProtection struct {
|
||||
config OverloadConfig
|
||||
logger slog.Logger
|
||||
|
||||
// concurrencyLimiter tracks the number of concurrent requests.
|
||||
currentConcurrency atomic.Int64
|
||||
|
||||
// rateLimiter is the rate limiting middleware.
|
||||
rateLimiter func(http.Handler) http.Handler
|
||||
}
|
||||
|
||||
// NewOverloadProtection creates a new OverloadProtection instance.
|
||||
func NewOverloadProtection(config OverloadConfig, logger slog.Logger) *OverloadProtection {
|
||||
op := &OverloadProtection{
|
||||
config: config,
|
||||
logger: logger.Named("overload"),
|
||||
}
|
||||
|
||||
// Initialize rate limiter if configured.
|
||||
if config.RateLimit > 0 && config.RateWindow > 0 {
|
||||
op.rateLimiter = httprate.Limit(
|
||||
int(config.RateLimit),
|
||||
config.RateWindow,
|
||||
httprate.WithKeyFuncs(httprate.KeyByIP),
|
||||
httprate.WithLimitHandler(func(w http.ResponseWriter, r *http.Request) {
|
||||
httpapi.Write(r.Context(), w, http.StatusTooManyRequests, codersdk.Response{
|
||||
Message: "AI Bridge rate limit exceeded. Please try again later.",
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
return op
|
||||
}
|
||||
|
||||
// ConcurrencyMiddleware returns a middleware that limits concurrent requests.
|
||||
// Returns nil if concurrency limiting is disabled.
|
||||
func (op *OverloadProtection) ConcurrencyMiddleware() func(http.Handler) http.Handler {
|
||||
if op.config.MaxConcurrency <= 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
current := op.currentConcurrency.Add(1)
|
||||
defer op.currentConcurrency.Add(-1)
|
||||
|
||||
if current > op.config.MaxConcurrency {
|
||||
op.logger.Warn(r.Context(), "ai bridge concurrency limit exceeded",
|
||||
slog.F("current", current),
|
||||
slog.F("max", op.config.MaxConcurrency),
|
||||
)
|
||||
httpapi.Write(r.Context(), w, http.StatusServiceUnavailable, codersdk.Response{
|
||||
Message: "AI Bridge is currently at capacity. Please try again later.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// RateLimitMiddleware returns a middleware that limits the rate of requests.
|
||||
// Returns nil if rate limiting is disabled.
|
||||
func (op *OverloadProtection) RateLimitMiddleware() func(http.Handler) http.Handler {
|
||||
return op.rateLimiter
|
||||
}
|
||||
|
||||
// CurrentConcurrency returns the current number of concurrent requests.
|
||||
func (op *OverloadProtection) CurrentConcurrency() int64 {
|
||||
return op.currentConcurrency.Load()
|
||||
}
|
||||
|
||||
// WrapHandler wraps the given handler with all enabled overload protection
|
||||
// middleware.
|
||||
func (op *OverloadProtection) WrapHandler(handler http.Handler) http.Handler {
|
||||
// Apply rate limiting first (cheaper check).
|
||||
if op.rateLimiter != nil {
|
||||
handler = op.rateLimiter(handler)
|
||||
}
|
||||
|
||||
// Then apply concurrency limiting.
|
||||
if concurrencyMW := op.ConcurrencyMiddleware(); concurrencyMW != nil {
|
||||
handler = concurrencyMW(handler)
|
||||
}
|
||||
|
||||
return handler
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
package aibridged_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"cdr.dev/slog"
|
||||
"cdr.dev/slog/sloggers/slogtest"
|
||||
"github.com/coder/coder/v2/enterprise/aibridged"
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
)
|
||||
|
||||
func TestOverloadProtection_ConcurrencyLimit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug)
|
||||
|
||||
t.Run("allows_requests_within_limit", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
op := aibridged.NewOverloadProtection(aibridged.OverloadConfig{
|
||||
MaxConcurrency: 5,
|
||||
}, logger)
|
||||
|
||||
var handlerCalls atomic.Int32
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
handlerCalls.Add(1)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
wrapped := op.WrapHandler(handler)
|
||||
|
||||
// Make 5 requests in sequence - all should succeed.
|
||||
for i := 0; i < 5; i++ {
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
wrapped.ServeHTTP(rec, req)
|
||||
assert.Equal(t, http.StatusOK, rec.Code)
|
||||
}
|
||||
|
||||
assert.Equal(t, int32(5), handlerCalls.Load())
|
||||
})
|
||||
|
||||
t.Run("rejects_requests_over_limit", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
op := aibridged.NewOverloadProtection(aibridged.OverloadConfig{
|
||||
MaxConcurrency: 2,
|
||||
}, logger)
|
||||
|
||||
// Create a handler that blocks until we release it.
|
||||
blocked := make(chan struct{})
|
||||
var handlerCalls atomic.Int32
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
handlerCalls.Add(1)
|
||||
<-blocked
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
wrapped := op.WrapHandler(handler)
|
||||
|
||||
// Start 2 requests that will block.
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 2; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
wrapped.ServeHTTP(rec, req)
|
||||
}()
|
||||
}
|
||||
|
||||
// Wait for the handlers to be called.
|
||||
require.Eventually(t, func() bool {
|
||||
return handlerCalls.Load() == 2
|
||||
}, testutil.WaitShort, testutil.IntervalFast)
|
||||
|
||||
// Make a third request - it should be rejected.
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
wrapped.ServeHTTP(rec, req)
|
||||
assert.Equal(t, http.StatusServiceUnavailable, rec.Code)
|
||||
|
||||
// Verify current concurrency is 2.
|
||||
assert.Equal(t, int64(2), op.CurrentConcurrency())
|
||||
|
||||
// Unblock the handlers.
|
||||
close(blocked)
|
||||
wg.Wait()
|
||||
|
||||
// Verify concurrency is back to 0.
|
||||
assert.Equal(t, int64(0), op.CurrentConcurrency())
|
||||
})
|
||||
|
||||
t.Run("disabled_when_zero", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
op := aibridged.NewOverloadProtection(aibridged.OverloadConfig{
|
||||
MaxConcurrency: 0, // Disabled.
|
||||
}, logger)
|
||||
|
||||
assert.Nil(t, op.ConcurrencyMiddleware())
|
||||
})
|
||||
}
|
||||
|
||||
func TestOverloadProtection_RateLimit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug)
|
||||
|
||||
t.Run("allows_requests_within_limit", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
op := aibridged.NewOverloadProtection(aibridged.OverloadConfig{
|
||||
RateLimit: 5,
|
||||
RateWindow: time.Minute,
|
||||
}, logger)
|
||||
|
||||
var handlerCalls atomic.Int32
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
handlerCalls.Add(1)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
wrapped := op.WrapHandler(handler)
|
||||
|
||||
// Make 5 requests - all should succeed.
|
||||
for i := 0; i < 5; i++ {
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
wrapped.ServeHTTP(rec, req)
|
||||
assert.Equal(t, http.StatusOK, rec.Code)
|
||||
}
|
||||
|
||||
assert.Equal(t, int32(5), handlerCalls.Load())
|
||||
})
|
||||
|
||||
t.Run("rejects_requests_over_limit", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
op := aibridged.NewOverloadProtection(aibridged.OverloadConfig{
|
||||
RateLimit: 2,
|
||||
RateWindow: time.Minute,
|
||||
}, logger)
|
||||
|
||||
var handlerCalls atomic.Int32
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
handlerCalls.Add(1)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
wrapped := op.WrapHandler(handler)
|
||||
|
||||
// Make 3 requests - first 2 should succeed, 3rd should be rate limited.
|
||||
for i := 0; i < 3; i++ {
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
wrapped.ServeHTTP(rec, req)
|
||||
|
||||
if i < 2 {
|
||||
assert.Equal(t, http.StatusOK, rec.Code)
|
||||
} else {
|
||||
assert.Equal(t, http.StatusTooManyRequests, rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
assert.Equal(t, int32(2), handlerCalls.Load())
|
||||
})
|
||||
|
||||
t.Run("disabled_when_zero", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
op := aibridged.NewOverloadProtection(aibridged.OverloadConfig{
|
||||
RateLimit: 0, // Disabled.
|
||||
}, logger)
|
||||
|
||||
assert.Nil(t, op.RateLimitMiddleware())
|
||||
})
|
||||
}
|
||||
|
||||
func TestOverloadProtection_Combined(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug)
|
||||
|
||||
t.Run("both_limits_applied", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
op := aibridged.NewOverloadProtection(aibridged.OverloadConfig{
|
||||
MaxConcurrency: 10,
|
||||
RateLimit: 3,
|
||||
RateWindow: time.Minute,
|
||||
}, logger)
|
||||
|
||||
var handlerCalls atomic.Int32
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
handlerCalls.Add(1)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
wrapped := op.WrapHandler(handler)
|
||||
|
||||
// Make 4 requests - first 3 should succeed, 4th should be rate limited.
|
||||
for i := 0; i < 4; i++ {
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
wrapped.ServeHTTP(rec, req)
|
||||
|
||||
if i < 3 {
|
||||
assert.Equal(t, http.StatusOK, rec.Code)
|
||||
} else {
|
||||
assert.Equal(t, http.StatusTooManyRequests, rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
assert.Equal(t, int32(3), handlerCalls.Load())
|
||||
})
|
||||
}
|
||||
@@ -7,13 +7,15 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/dgraph-io/ristretto/v2"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
"golang.org/x/xerrors"
|
||||
"tailscale.com/util/singleflight"
|
||||
|
||||
"cdr.dev/slog"
|
||||
|
||||
"github.com/coder/aibridge"
|
||||
"github.com/coder/aibridge/mcp"
|
||||
"github.com/coder/aibridge/tracing"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -52,12 +54,13 @@ type CachedBridgePool struct {
|
||||
singleflight *singleflight.Group[string, *aibridge.RequestBridge]
|
||||
|
||||
metrics *aibridge.Metrics
|
||||
tracer trace.Tracer
|
||||
|
||||
shutDownOnce sync.Once
|
||||
shuttingDownCh chan struct{}
|
||||
}
|
||||
|
||||
func NewCachedBridgePool(options PoolOptions, providers []aibridge.Provider, metrics *aibridge.Metrics, logger slog.Logger) (*CachedBridgePool, error) {
|
||||
func NewCachedBridgePool(options PoolOptions, providers []aibridge.Provider, logger slog.Logger, metrics *aibridge.Metrics, tracer trace.Tracer) (*CachedBridgePool, error) {
|
||||
cache, err := ristretto.NewCache(&ristretto.Config[string, *aibridge.RequestBridge]{
|
||||
NumCounters: options.MaxItems * 10, // Docs suggest setting this 10x number of keys.
|
||||
MaxCost: options.MaxItems * cacheCost, // Up to n instances.
|
||||
@@ -85,13 +88,13 @@ func NewCachedBridgePool(options PoolOptions, providers []aibridge.Provider, met
|
||||
return &CachedBridgePool{
|
||||
cache: cache,
|
||||
providers: providers,
|
||||
logger: logger,
|
||||
options: options,
|
||||
metrics: metrics,
|
||||
tracer: tracer,
|
||||
logger: logger,
|
||||
|
||||
singleflight: &singleflight.Group[string, *aibridge.RequestBridge]{},
|
||||
|
||||
metrics: metrics,
|
||||
|
||||
shuttingDownCh: make(chan struct{}),
|
||||
}, nil
|
||||
}
|
||||
@@ -100,7 +103,15 @@ func NewCachedBridgePool(options PoolOptions, providers []aibridge.Provider, met
|
||||
//
|
||||
// Each returned [*aibridge.RequestBridge] is safe for concurrent use.
|
||||
// Each [*aibridge.RequestBridge] is stateful because it has MCP clients which maintain sessions to the configured MCP server.
|
||||
func (p *CachedBridgePool) Acquire(ctx context.Context, req Request, clientFn ClientFunc, mcpProxyFactory MCPProxyBuilder) (http.Handler, error) {
|
||||
func (p *CachedBridgePool) Acquire(ctx context.Context, req Request, clientFn ClientFunc, mcpProxyFactory MCPProxyBuilder) (_ http.Handler, outErr error) {
|
||||
spanAttrs := []attribute.KeyValue{
|
||||
attribute.String(tracing.InitiatorID, req.InitiatorID.String()),
|
||||
attribute.String(tracing.APIKeyID, req.APIKeyID),
|
||||
}
|
||||
ctx, span := p.tracer.Start(ctx, "CachedBridgePool.Acquire", trace.WithAttributes(spanAttrs...))
|
||||
defer tracing.EndSpanErr(span, &outErr)
|
||||
ctx = tracing.WithRequestBridgeAttributesInContext(ctx, spanAttrs)
|
||||
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, xerrors.Errorf("acquire: %w", err)
|
||||
}
|
||||
@@ -124,10 +135,12 @@ func (p *CachedBridgePool) Acquire(ctx context.Context, req Request, clientFn Cl
|
||||
// expire after the original TTL; we can extend the TTL on each Acquire() call.
|
||||
// For now, we need to let the instance expiry to keep the MCP connections fresh.
|
||||
|
||||
span.AddEvent("cache_hit")
|
||||
return bridge, nil
|
||||
}
|
||||
|
||||
recorder := aibridge.NewRecorder(p.logger.Named("recorder"), func() (aibridge.Recorder, error) {
|
||||
span.AddEvent("cache_miss")
|
||||
recorder := aibridge.NewRecorder(p.logger.Named("recorder"), p.tracer, func() (aibridge.Recorder, error) {
|
||||
client, err := clientFn()
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("acquire client: %w", err)
|
||||
@@ -145,7 +158,7 @@ func (p *CachedBridgePool) Acquire(ctx context.Context, req Request, clientFn Cl
|
||||
err error
|
||||
)
|
||||
|
||||
mcpServers, err = mcpProxyFactory.Build(ctx, req)
|
||||
mcpServers, err = mcpProxyFactory.Build(ctx, req, p.tracer)
|
||||
if err != nil {
|
||||
p.logger.Warn(ctx, "failed to create MCP server proxiers", slog.Error(err))
|
||||
// Don't fail here; MCP server injection can gracefully degrade.
|
||||
@@ -158,7 +171,7 @@ func (p *CachedBridgePool) Acquire(ctx context.Context, req Request, clientFn Cl
|
||||
}
|
||||
}
|
||||
|
||||
bridge, err := aibridge.NewRequestBridge(ctx, p.providers, recorder, mcpServers, p.metrics, p.logger)
|
||||
bridge, err := aibridge.NewRequestBridge(ctx, p.providers, recorder, mcpServers, p.logger, p.metrics, p.tracer)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("create new request bridge: %w", err)
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
"go.uber.org/mock/gomock"
|
||||
|
||||
"cdr.dev/slog/sloggers/slogtest"
|
||||
@@ -30,7 +31,7 @@ func TestPool(t *testing.T) {
|
||||
mcpProxy := mcpmock.NewMockServerProxier(ctrl)
|
||||
|
||||
opts := aibridged.PoolOptions{MaxItems: 1, TTL: time.Second}
|
||||
pool, err := aibridged.NewCachedBridgePool(opts, nil, nil, logger)
|
||||
pool, err := aibridged.NewCachedBridgePool(opts, nil, logger, nil, testTracer)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { pool.Shutdown(context.Background()) })
|
||||
|
||||
@@ -120,6 +121,6 @@ func newMockMCPFactory(proxy *mcpmock.MockServerProxier) *mockMCPFactory {
|
||||
return &mockMCPFactory{proxy: proxy}
|
||||
}
|
||||
|
||||
func (m *mockMCPFactory) Build(ctx context.Context, req aibridged.Request) (mcp.ServerProxier, error) {
|
||||
func (m *mockMCPFactory) Build(ctx context.Context, req aibridged.Request, tracer trace.Tracer) (mcp.ServerProxier, error) {
|
||||
return m.proxy, nil
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
|
||||
"github.com/coder/aibridge"
|
||||
"github.com/coder/coder/v2/coderd/tracing"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/enterprise/aibridged"
|
||||
"github.com/coder/coder/v2/enterprise/coderd"
|
||||
@@ -35,17 +36,29 @@ func newAIBridgeDaemon(coderAPI *coderd.API) (*aibridged.Server, error) {
|
||||
|
||||
reg := prometheus.WrapRegistererWithPrefix("coder_aibridged_", coderAPI.PrometheusRegistry)
|
||||
metrics := aibridge.NewMetrics(reg)
|
||||
tracer := coderAPI.TracerProvider.Tracer(tracing.TracerName)
|
||||
|
||||
// Create pool for reusable stateful [aibridge.RequestBridge] instances (one per user).
|
||||
pool, err := aibridged.NewCachedBridgePool(aibridged.DefaultPoolOptions, providers, metrics, logger.Named("pool")) // TODO: configurable size.
|
||||
pool, err := aibridged.NewCachedBridgePool(aibridged.DefaultPoolOptions, providers, logger.Named("pool"), metrics, tracer) // TODO: configurable size.
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("create request pool: %w", err)
|
||||
}
|
||||
|
||||
// Configure overload protection if any limits are set.
|
||||
var overloadCfg *aibridged.OverloadConfig
|
||||
bridgeCfg := coderAPI.DeploymentValues.AI.BridgeConfig
|
||||
if bridgeCfg.MaxConcurrency.Value() > 0 || bridgeCfg.RateLimit.Value() > 0 {
|
||||
overloadCfg = &aibridged.OverloadConfig{
|
||||
MaxConcurrency: bridgeCfg.MaxConcurrency.Value(),
|
||||
RateLimit: bridgeCfg.RateLimit.Value(),
|
||||
RateWindow: bridgeCfg.RateWindow.Value(),
|
||||
}
|
||||
}
|
||||
|
||||
// Create daemon.
|
||||
srv, err := aibridged.New(ctx, pool, func(dialCtx context.Context) (aibridged.DRPCClient, error) {
|
||||
return coderAPI.CreateInMemoryAIBridgeServer(dialCtx)
|
||||
}, logger)
|
||||
}, logger, tracer, overloadCfg)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("start in-memory aibridge daemon: %w", err)
|
||||
}
|
||||
|
||||
@@ -119,12 +119,23 @@ AI BRIDGE OPTIONS:
|
||||
requests (requires the "oauth2" and "mcp-server-http" experiments to
|
||||
be enabled).
|
||||
|
||||
--aibridge-max-concurrency int, $CODER_AIBRIDGE_MAX_CONCURRENCY (default: 0)
|
||||
Maximum number of concurrent AI Bridge requests. Set to 0 to disable
|
||||
(unlimited).
|
||||
|
||||
--aibridge-openai-base-url string, $CODER_AIBRIDGE_OPENAI_BASE_URL (default: https://api.openai.com/v1/)
|
||||
The base URL of the OpenAI API.
|
||||
|
||||
--aibridge-openai-key string, $CODER_AIBRIDGE_OPENAI_KEY
|
||||
The key to authenticate against the OpenAI API.
|
||||
|
||||
--aibridge-rate-limit int, $CODER_AIBRIDGE_RATE_LIMIT (default: 0)
|
||||
Maximum number of AI Bridge requests per rate window. Set to 0 to
|
||||
disable rate limiting.
|
||||
|
||||
--aibridge-rate-window duration, $CODER_AIBRIDGE_RATE_WINDOW (default: 1m)
|
||||
Duration of the rate limiting window for AI Bridge requests.
|
||||
|
||||
CLIENT OPTIONS:
|
||||
These options change the behavior of how clients interact with the Coder.
|
||||
Clients include the Coder CLI, Coder Desktop, IDE extensions, and the web UI.
|
||||
|
||||
@@ -59,7 +59,7 @@ var Keys = map[string]ed25519.PublicKey{"2022-08-12": ed25519.PublicKey(key20220
|
||||
// @Security CoderSessionToken
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Tags Organizations
|
||||
// @Tags Enterprise
|
||||
// @Param request body codersdk.AddLicenseRequest true "Add license request"
|
||||
// @Success 201 {object} codersdk.License
|
||||
// @Router /licenses [post]
|
||||
@@ -163,7 +163,7 @@ func (api *API) postLicense(rw http.ResponseWriter, r *http.Request) {
|
||||
// @ID update-license-entitlements
|
||||
// @Security CoderSessionToken
|
||||
// @Produce json
|
||||
// @Tags Organizations
|
||||
// @Tags Enterprise
|
||||
// @Success 201 {object} codersdk.Response
|
||||
// @Router /licenses/refresh-entitlements [post]
|
||||
func (api *API) postRefreshEntitlements(rw http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -275,14 +275,14 @@ module "code-server" {
|
||||
module "windsurf" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/windsurf/coder"
|
||||
version = "1.2.1"
|
||||
version = "1.3.0"
|
||||
agent_id = coder_agent.main.id
|
||||
}
|
||||
|
||||
module "cursor" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/cursor/coder"
|
||||
version = "1.3.3"
|
||||
version = "1.4.0"
|
||||
agent_id = coder_agent.main.id
|
||||
}
|
||||
|
||||
|
||||
@@ -82,7 +82,7 @@ require (
|
||||
github.com/aquasecurity/trivy-iac v0.8.0
|
||||
github.com/armon/circbuf v0.0.0-20190214190532-5111143e8da2
|
||||
github.com/awalterschulze/gographviz v2.0.3+incompatible
|
||||
github.com/aws/smithy-go v1.23.2
|
||||
github.com/aws/smithy-go v1.24.0
|
||||
github.com/bramvdbogaerde/go-scp v1.5.0
|
||||
github.com/briandowns/spinner v1.23.0
|
||||
github.com/cakturk/go-netstat v0.0.0-20200220111822-e5b49efee7a5
|
||||
@@ -206,7 +206,7 @@ require (
|
||||
golang.org/x/text v0.31.0
|
||||
golang.org/x/tools v0.39.0
|
||||
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da
|
||||
google.golang.org/api v0.256.0
|
||||
google.golang.org/api v0.257.0
|
||||
google.golang.org/grpc v1.77.0
|
||||
google.golang.org/protobuf v1.36.10
|
||||
gopkg.in/DataDog/dd-trace-go.v1 v1.74.0
|
||||
@@ -453,7 +453,7 @@ require (
|
||||
google.golang.org/appengine v1.6.8 // indirect
|
||||
google.golang.org/genproto v0.0.0-20250715232539-7130f93afb79 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251103181224-f26f9409b101 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251124214823-79d6a2a48846 // indirect
|
||||
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||
howett.net/plist v1.0.0 // indirect
|
||||
kernel.org/pub/linux/libs/security/libcap/psx v1.2.73 // indirect
|
||||
@@ -474,9 +474,9 @@ require (
|
||||
|
||||
require (
|
||||
github.com/anthropics/anthropic-sdk-go v1.19.0
|
||||
github.com/brianvoe/gofakeit/v7 v7.9.0
|
||||
github.com/brianvoe/gofakeit/v7 v7.12.1
|
||||
github.com/coder/agentapi-sdk-go v0.0.0-20250505131810-560d1d88d225
|
||||
github.com/coder/aibridge v0.2.1
|
||||
github.com/coder/aibridge v0.3.0
|
||||
github.com/coder/aisdk-go v0.0.9
|
||||
github.com/coder/boundary v1.0.1-0.20250925154134-55a44f2a7945
|
||||
github.com/coder/preview v1.0.4
|
||||
|
||||
@@ -794,8 +794,8 @@ github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.9 h1:LU8S9W/mPDAU9q0FjCLi0TrC
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.9/go.mod h1:/j67Z5XBVDx8nZVp9EuFM9/BS5dvBznbqILGuu73hug=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.1 h1:GdGmKtG+/Krag7VfyOXV17xjTCz0i9NT+JnqLTOI5nA=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.1/go.mod h1:6TxbXoDSgBQ225Qd8Q+MbxUxUh6TtNKwbRt/EPS9xso=
|
||||
github.com/aws/smithy-go v1.23.2 h1:Crv0eatJUQhaManss33hS5r40CG3ZFH+21XSkqMrIUM=
|
||||
github.com/aws/smithy-go v1.23.2/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0=
|
||||
github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk=
|
||||
github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||
github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8=
|
||||
@@ -844,8 +844,8 @@ github.com/boombuler/barcode v1.0.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl
|
||||
github.com/boombuler/barcode v1.0.1/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
||||
github.com/bramvdbogaerde/go-scp v1.5.0 h1:a9BinAjTfQh273eh7vd3qUgmBC+bx+3TRDtkZWmIpzM=
|
||||
github.com/bramvdbogaerde/go-scp v1.5.0/go.mod h1:on2aH5AxaFb2G0N5Vsdy6B0Ml7k9HuHSwfo1y0QzAbQ=
|
||||
github.com/brianvoe/gofakeit/v7 v7.9.0 h1:6NsaMy9D5ZKVwIZ1V8L//J2FrOF3546FcXDElWLx994=
|
||||
github.com/brianvoe/gofakeit/v7 v7.9.0/go.mod h1:QXuPeBw164PJCzCUZVmgpgHJ3Llj49jSLVkKPMtxtxA=
|
||||
github.com/brianvoe/gofakeit/v7 v7.12.1 h1:df1tiI4SL1dR5Ix4D/r6a3a+nXBJ/OBGU5jEKRBmmqg=
|
||||
github.com/brianvoe/gofakeit/v7 v7.12.1/go.mod h1:QXuPeBw164PJCzCUZVmgpgHJ3Llj49jSLVkKPMtxtxA=
|
||||
github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs=
|
||||
github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
|
||||
github.com/bytecodealliance/wasmtime-go/v3 v3.0.2 h1:3uZCA/BLTIu+DqCfguByNMJa2HVHpXvjfy0Dy7g6fuA=
|
||||
@@ -919,8 +919,8 @@ github.com/cncf/xds/go v0.0.0-20251022180443-0feb69152e9f h1:Y8xYupdHxryycyPlc9Y
|
||||
github.com/cncf/xds/go v0.0.0-20251022180443-0feb69152e9f/go.mod h1:HlzOvOjVBOfTGSRXRyY0OiCS/3J1akRGQQpRO/7zyF4=
|
||||
github.com/coder/agentapi-sdk-go v0.0.0-20250505131810-560d1d88d225 h1:tRIViZ5JRmzdOEo5wUWngaGEFBG8OaE1o2GIHN5ujJ8=
|
||||
github.com/coder/agentapi-sdk-go v0.0.0-20250505131810-560d1d88d225/go.mod h1:rNLVpYgEVeu1Zk29K64z6Od8RBP9DwqCu9OfCzh8MR4=
|
||||
github.com/coder/aibridge v0.2.1 h1:Z0DHkV5ADbIF6DnnRaNZEElmaPG5umawBGFp10RoMVM=
|
||||
github.com/coder/aibridge v0.2.1/go.mod h1:2T0RSnIX1WTqFajzXsaNsoNe6mmNsNeCTxiHBWEsFnE=
|
||||
github.com/coder/aibridge v0.3.0 h1:z5coky9A5uXOr+zjgmsynal8PVYBMmxE9u1vcIzs4t8=
|
||||
github.com/coder/aibridge v0.3.0/go.mod h1:ENnl6VhU8Qot5OuVYqs7V4vXII11oKBWgWKrgIJbRAs=
|
||||
github.com/coder/aisdk-go v0.0.9 h1:Vzo/k2qwVGLTR10ESDeP2Ecek1SdPfZlEjtTfMveiVo=
|
||||
github.com/coder/aisdk-go v0.0.9/go.mod h1:KF6/Vkono0FJJOtWtveh5j7yfNrSctVTpwgweYWSp5M=
|
||||
github.com/coder/boundary v1.0.1-0.20250925154134-55a44f2a7945 h1:hDUf02kTX8EGR3+5B+v5KdYvORs4YNfDPci0zCs+pC0=
|
||||
@@ -2547,8 +2547,8 @@ google.golang.org/api v0.108.0/go.mod h1:2Ts0XTHNVWxypznxWOYUeI4g3WdP9Pk2Qk58+a/
|
||||
google.golang.org/api v0.110.0/go.mod h1:7FC4Vvx1Mooxh8C5HWjzZHcavuS2f6pmJpZx60ca7iI=
|
||||
google.golang.org/api v0.111.0/go.mod h1:qtFHvU9mhgTJegR31csQ+rwxyUTHOKFqCKWp1J0fdw0=
|
||||
google.golang.org/api v0.114.0/go.mod h1:ifYI2ZsFK6/uGddGfAD5BMxlnkBqCmqHSDUVi45N5Yg=
|
||||
google.golang.org/api v0.256.0 h1:u6Khm8+F9sxbCTYNoBHg6/Hwv0N/i+V94MvkOSor6oI=
|
||||
google.golang.org/api v0.256.0/go.mod h1:KIgPhksXADEKJlnEoRa9qAII4rXcy40vfI8HRqcU964=
|
||||
google.golang.org/api v0.257.0 h1:8Y0lzvHlZps53PEaw+G29SsQIkuKrumGWs9puiexNAA=
|
||||
google.golang.org/api v0.257.0/go.mod h1:4eJrr+vbVaZSqs7vovFd1Jb/A6ml6iw2e6FBYf3GAO4=
|
||||
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
||||
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||
google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||
@@ -2693,8 +2693,8 @@ google.golang.org/genproto v0.0.0-20250715232539-7130f93afb79 h1:Nt6z9UHqSlIdIGJ
|
||||
google.golang.org/genproto v0.0.0-20250715232539-7130f93afb79/go.mod h1:kTmlBHMPqR5uCZPBvwa2B18mvubkjyY3CRLI0c6fj0s=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8 h1:mepRgnBZa07I4TRuomDE4sTIYieg/osKmzIf4USdWS4=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8/go.mod h1:fDMmzKV90WSg1NbozdqrE64fkuTv6mlq2zxo9ad+3yo=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251103181224-f26f9409b101 h1:tRPGkdGHuewF4UisLzzHHr1spKw92qLM98nIzxbC0wY=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251103181224-f26f9409b101/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251124214823-79d6a2a48846 h1:Wgl1rcDNThT+Zn47YyCXOXyX/COgMTIdhJ717F0l4xk=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251124214823-79d6a2a48846/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
|
||||
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
|
||||
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
|
||||
google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"framer-motion": "^10.18.0",
|
||||
"front-matter": "4.0.2",
|
||||
"lodash": "4.17.21",
|
||||
"next": "15.5.6",
|
||||
"next": "15.5.7",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"react-icons": "4.12.0",
|
||||
|
||||
Generated
+172
-151
@@ -34,8 +34,8 @@ importers:
|
||||
specifier: 4.17.21
|
||||
version: 4.17.21
|
||||
next:
|
||||
specifier: 15.5.6
|
||||
version: 15.5.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
specifier: 15.5.7
|
||||
version: 15.5.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
react:
|
||||
specifier: 18.3.1
|
||||
version: 18.3.1
|
||||
@@ -175,8 +175,8 @@ packages:
|
||||
'@emnapi/core@1.5.0':
|
||||
resolution: {integrity: sha512-sbP8GzB1WDzacS8fgNPpHlp6C9VZe+SJP3F90W9rLemaQj2PzIuTEl1qDOYQf58YIpyjViI24y9aPWCjEzY2cg==}
|
||||
|
||||
'@emnapi/runtime@1.6.0':
|
||||
resolution: {integrity: sha512-obtUmAHTMjll499P+D9A3axeJFlhdjOWdKUNs/U6QIGT7V5RjcUW1xToAzjvmgTSQhDbYn/NwfTRoJcQ2rNBxA==}
|
||||
'@emnapi/runtime@1.7.1':
|
||||
resolution: {integrity: sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA==}
|
||||
|
||||
'@emnapi/wasi-threads@1.1.0':
|
||||
resolution: {integrity: sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==}
|
||||
@@ -286,124 +286,135 @@ packages:
|
||||
resolution: {integrity: sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@img/sharp-darwin-arm64@0.34.4':
|
||||
resolution: {integrity: sha512-sitdlPzDVyvmINUdJle3TNHl+AG9QcwiAMsXmccqsCOMZNIdW2/7S26w0LyU8euiLVzFBL3dXPwVCq/ODnf2vA==}
|
||||
'@img/sharp-darwin-arm64@0.34.5':
|
||||
resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@img/sharp-darwin-x64@0.34.4':
|
||||
resolution: {integrity: sha512-rZheupWIoa3+SOdF/IcUe1ah4ZDpKBGWcsPX6MT0lYniH9micvIU7HQkYTfrx5Xi8u+YqwLtxC/3vl8TQN6rMg==}
|
||||
'@img/sharp-darwin-x64@0.34.5':
|
||||
resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@img/sharp-libvips-darwin-arm64@1.2.3':
|
||||
resolution: {integrity: sha512-QzWAKo7kpHxbuHqUC28DZ9pIKpSi2ts2OJnoIGI26+HMgq92ZZ4vk8iJd4XsxN+tYfNJxzH6W62X5eTcsBymHw==}
|
||||
'@img/sharp-libvips-darwin-arm64@1.2.4':
|
||||
resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@img/sharp-libvips-darwin-x64@1.2.3':
|
||||
resolution: {integrity: sha512-Ju+g2xn1E2AKO6YBhxjj+ACcsPQRHT0bhpglxcEf+3uyPY+/gL8veniKoo96335ZaPo03bdDXMv0t+BBFAbmRA==}
|
||||
'@img/sharp-libvips-darwin-x64@1.2.4':
|
||||
resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@img/sharp-libvips-linux-arm64@1.2.3':
|
||||
resolution: {integrity: sha512-I4RxkXU90cpufazhGPyVujYwfIm9Nk1QDEmiIsaPwdnm013F7RIceaCc87kAH+oUB1ezqEvC6ga4m7MSlqsJvQ==}
|
||||
'@img/sharp-libvips-linux-arm64@1.2.4':
|
||||
resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@img/sharp-libvips-linux-arm@1.2.3':
|
||||
resolution: {integrity: sha512-x1uE93lyP6wEwGvgAIV0gP6zmaL/a0tGzJs/BIDDG0zeBhMnuUPm7ptxGhUbcGs4okDJrk4nxgrmxpib9g6HpA==}
|
||||
'@img/sharp-libvips-linux-arm@1.2.4':
|
||||
resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@img/sharp-libvips-linux-ppc64@1.2.3':
|
||||
resolution: {integrity: sha512-Y2T7IsQvJLMCBM+pmPbM3bKT/yYJvVtLJGfCs4Sp95SjvnFIjynbjzsa7dY1fRJX45FTSfDksbTp6AGWudiyCg==}
|
||||
'@img/sharp-libvips-linux-ppc64@1.2.4':
|
||||
resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
|
||||
'@img/sharp-libvips-linux-s390x@1.2.3':
|
||||
resolution: {integrity: sha512-RgWrs/gVU7f+K7P+KeHFaBAJlNkD1nIZuVXdQv6S+fNA6syCcoboNjsV2Pou7zNlVdNQoQUpQTk8SWDHUA3y/w==}
|
||||
'@img/sharp-libvips-linux-riscv64@1.2.4':
|
||||
resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
|
||||
'@img/sharp-libvips-linux-s390x@1.2.4':
|
||||
resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
|
||||
'@img/sharp-libvips-linux-x64@1.2.3':
|
||||
resolution: {integrity: sha512-3JU7LmR85K6bBiRzSUc/Ff9JBVIFVvq6bomKE0e63UXGeRw2HPVEjoJke1Yx+iU4rL7/7kUjES4dZ/81Qjhyxg==}
|
||||
'@img/sharp-libvips-linux-x64@1.2.4':
|
||||
resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@img/sharp-libvips-linuxmusl-arm64@1.2.3':
|
||||
resolution: {integrity: sha512-F9q83RZ8yaCwENw1GieztSfj5msz7GGykG/BA+MOUefvER69K/ubgFHNeSyUu64amHIYKGDs4sRCMzXVj8sEyw==}
|
||||
'@img/sharp-libvips-linuxmusl-arm64@1.2.4':
|
||||
resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@img/sharp-libvips-linuxmusl-x64@1.2.3':
|
||||
resolution: {integrity: sha512-U5PUY5jbc45ANM6tSJpsgqmBF/VsL6LnxJmIf11kB7J5DctHgqm0SkuXzVWtIY90GnJxKnC/JT251TDnk1fu/g==}
|
||||
'@img/sharp-libvips-linuxmusl-x64@1.2.4':
|
||||
resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@img/sharp-linux-arm64@0.34.4':
|
||||
resolution: {integrity: sha512-YXU1F/mN/Wu786tl72CyJjP/Ngl8mGHN1hST4BGl+hiW5jhCnV2uRVTNOcaYPs73NeT/H8Upm3y9582JVuZHrQ==}
|
||||
'@img/sharp-linux-arm64@0.34.5':
|
||||
resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@img/sharp-linux-arm@0.34.4':
|
||||
resolution: {integrity: sha512-Xyam4mlqM0KkTHYVSuc6wXRmM7LGN0P12li03jAnZ3EJWZqj83+hi8Y9UxZUbxsgsK1qOEwg7O0Bc0LjqQVtxA==}
|
||||
'@img/sharp-linux-arm@0.34.5':
|
||||
resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@img/sharp-linux-ppc64@0.34.4':
|
||||
resolution: {integrity: sha512-F4PDtF4Cy8L8hXA2p3TO6s4aDt93v+LKmpcYFLAVdkkD3hSxZzee0rh6/+94FpAynsuMpLX5h+LRsSG3rIciUQ==}
|
||||
'@img/sharp-linux-ppc64@0.34.5':
|
||||
resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
|
||||
'@img/sharp-linux-s390x@0.34.4':
|
||||
resolution: {integrity: sha512-qVrZKE9Bsnzy+myf7lFKvng6bQzhNUAYcVORq2P7bDlvmF6u2sCmK2KyEQEBdYk+u3T01pVsPrkj943T1aJAsw==}
|
||||
'@img/sharp-linux-riscv64@0.34.5':
|
||||
resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
|
||||
'@img/sharp-linux-s390x@0.34.5':
|
||||
resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
|
||||
'@img/sharp-linux-x64@0.34.4':
|
||||
resolution: {integrity: sha512-ZfGtcp2xS51iG79c6Vhw9CWqQC8l2Ot8dygxoDoIQPTat/Ov3qAa8qpxSrtAEAJW+UjTXc4yxCjNfxm4h6Xm2A==}
|
||||
'@img/sharp-linux-x64@0.34.5':
|
||||
resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@img/sharp-linuxmusl-arm64@0.34.4':
|
||||
resolution: {integrity: sha512-8hDVvW9eu4yHWnjaOOR8kHVrew1iIX+MUgwxSuH2XyYeNRtLUe4VNioSqbNkB7ZYQJj9rUTT4PyRscyk2PXFKA==}
|
||||
'@img/sharp-linuxmusl-arm64@0.34.5':
|
||||
resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@img/sharp-linuxmusl-x64@0.34.4':
|
||||
resolution: {integrity: sha512-lU0aA5L8QTlfKjpDCEFOZsTYGn3AEiO6db8W5aQDxj0nQkVrZWmN3ZP9sYKWJdtq3PWPhUNlqehWyXpYDcI9Sg==}
|
||||
'@img/sharp-linuxmusl-x64@0.34.5':
|
||||
resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@img/sharp-wasm32@0.34.4':
|
||||
resolution: {integrity: sha512-33QL6ZO/qpRyG7woB/HUALz28WnTMI2W1jgX3Nu2bypqLIKx/QKMILLJzJjI+SIbvXdG9fUnmrxR7vbi1sTBeA==}
|
||||
'@img/sharp-wasm32@0.34.5':
|
||||
resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [wasm32]
|
||||
|
||||
'@img/sharp-win32-arm64@0.34.4':
|
||||
resolution: {integrity: sha512-2Q250do/5WXTwxW3zjsEuMSv5sUU4Tq9VThWKlU2EYLm4MB7ZeMwF+SFJutldYODXF6jzc6YEOC+VfX0SZQPqA==}
|
||||
'@img/sharp-win32-arm64@0.34.5':
|
||||
resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@img/sharp-win32-ia32@0.34.4':
|
||||
resolution: {integrity: sha512-3ZeLue5V82dT92CNL6rsal6I2weKw1cYu+rGKm8fOCCtJTR2gYeUfY3FqUnIJsMUPIH68oS5jmZ0NiJ508YpEw==}
|
||||
'@img/sharp-win32-ia32@0.34.5':
|
||||
resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [ia32]
|
||||
os: [win32]
|
||||
|
||||
'@img/sharp-win32-x64@0.34.4':
|
||||
resolution: {integrity: sha512-xIyj4wpYs8J18sVN3mSQjwrw7fKUqRw+Z5rnHNCy5fYTxigBz81u5mOMPmFumwjcn8+ld1ppptMBCLic1nz6ig==}
|
||||
'@img/sharp-win32-x64@0.34.5':
|
||||
resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
@@ -428,56 +439,56 @@ packages:
|
||||
'@napi-rs/wasm-runtime@0.2.12':
|
||||
resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==}
|
||||
|
||||
'@next/env@15.5.6':
|
||||
resolution: {integrity: sha512-3qBGRW+sCGzgbpc5TS1a0p7eNxnOarGVQhZxfvTdnV0gFI61lX7QNtQ4V1TSREctXzYn5NetbUsLvyqwLFJM6Q==}
|
||||
'@next/env@15.5.7':
|
||||
resolution: {integrity: sha512-4h6Y2NyEkIEN7Z8YxkA27pq6zTkS09bUSYC0xjd0NpwFxjnIKeZEeH591o5WECSmjpUhLn3H2QLJcDye3Uzcvg==}
|
||||
|
||||
'@next/eslint-plugin-next@14.2.33':
|
||||
resolution: {integrity: sha512-DQTJFSvlB+9JilwqMKJ3VPByBNGxAGFTfJ7BuFj25cVcbBy7jm88KfUN+dngM4D3+UxZ8ER2ft+WH9JccMvxyg==}
|
||||
|
||||
'@next/swc-darwin-arm64@15.5.6':
|
||||
resolution: {integrity: sha512-ES3nRz7N+L5Umz4KoGfZ4XX6gwHplwPhioVRc25+QNsDa7RtUF/z8wJcbuQ2Tffm5RZwuN2A063eapoJ1u4nPg==}
|
||||
'@next/swc-darwin-arm64@15.5.7':
|
||||
resolution: {integrity: sha512-IZwtxCEpI91HVU/rAUOOobWSZv4P2DeTtNaCdHqLcTJU4wdNXgAySvKa/qJCgR5m6KI8UsKDXtO2B31jcaw1Yw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@next/swc-darwin-x64@15.5.6':
|
||||
resolution: {integrity: sha512-JIGcytAyk9LQp2/nuVZPAtj8uaJ/zZhsKOASTjxDug0SPU9LAM3wy6nPU735M1OqacR4U20LHVF5v5Wnl9ptTA==}
|
||||
'@next/swc-darwin-x64@15.5.7':
|
||||
resolution: {integrity: sha512-UP6CaDBcqaCBuiq/gfCEJw7sPEoX1aIjZHnBWN9v9qYHQdMKvCKcAVs4OX1vIjeE+tC5EIuwDTVIoXpUes29lg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@next/swc-linux-arm64-gnu@15.5.6':
|
||||
resolution: {integrity: sha512-qvz4SVKQ0P3/Im9zcS2RmfFL/UCQnsJKJwQSkissbngnB/12c6bZTCB0gHTexz1s6d/mD0+egPKXAIRFVS7hQg==}
|
||||
'@next/swc-linux-arm64-gnu@15.5.7':
|
||||
resolution: {integrity: sha512-NCslw3GrNIw7OgmRBxHtdWFQYhexoUCq+0oS2ccjyYLtcn1SzGzeM54jpTFonIMUjNbHmpKpziXnpxhSWLcmBA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@next/swc-linux-arm64-musl@15.5.6':
|
||||
resolution: {integrity: sha512-FsbGVw3SJz1hZlvnWD+T6GFgV9/NYDeLTNQB2MXoPN5u9VA9OEDy6fJEfePfsUKAhJufFbZLgp0cPxMuV6SV0w==}
|
||||
'@next/swc-linux-arm64-musl@15.5.7':
|
||||
resolution: {integrity: sha512-nfymt+SE5cvtTrG9u1wdoxBr9bVB7mtKTcj0ltRn6gkP/2Nu1zM5ei8rwP9qKQP0Y//umK+TtkKgNtfboBxRrw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@next/swc-linux-x64-gnu@15.5.6':
|
||||
resolution: {integrity: sha512-3QnHGFWlnvAgyxFxt2Ny8PTpXtQD7kVEeaFat5oPAHHI192WKYB+VIKZijtHLGdBBvc16tiAkPTDmQNOQ0dyrA==}
|
||||
'@next/swc-linux-x64-gnu@15.5.7':
|
||||
resolution: {integrity: sha512-hvXcZvCaaEbCZcVzcY7E1uXN9xWZfFvkNHwbe/n4OkRhFWrs1J1QV+4U1BN06tXLdaS4DazEGXwgqnu/VMcmqw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@next/swc-linux-x64-musl@15.5.6':
|
||||
resolution: {integrity: sha512-OsGX148sL+TqMK9YFaPFPoIaJKbFJJxFzkXZljIgA9hjMjdruKht6xDCEv1HLtlLNfkx3c5w2GLKhj7veBQizQ==}
|
||||
'@next/swc-linux-x64-musl@15.5.7':
|
||||
resolution: {integrity: sha512-4IUO539b8FmF0odY6/SqANJdgwn1xs1GkPO5doZugwZ3ETF6JUdckk7RGmsfSf7ws8Qb2YB5It33mvNL/0acqA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@next/swc-win32-arm64-msvc@15.5.6':
|
||||
resolution: {integrity: sha512-ONOMrqWxdzXDJNh2n60H6gGyKed42Ieu6UTVPZteXpuKbLZTH4G4eBMsr5qWgOBA+s7F+uB4OJbZnrkEDnZ5Fg==}
|
||||
'@next/swc-win32-arm64-msvc@15.5.7':
|
||||
resolution: {integrity: sha512-CpJVTkYI3ZajQkC5vajM7/ApKJUOlm6uP4BknM3XKvJ7VXAvCqSjSLmM0LKdYzn6nBJVSjdclx8nYJSa3xlTgQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@next/swc-win32-x64-msvc@15.5.6':
|
||||
resolution: {integrity: sha512-pxK4VIjFRx1MY92UycLOOw7dTdvccWsNETQ0kDHkBlcFH1GrTLUjSiHU1ohrznnux6TqRHgv5oflhfIWZwVROQ==}
|
||||
'@next/swc-win32-x64-msvc@15.5.7':
|
||||
resolution: {integrity: sha512-gMzgBX164I6DN+9/PGA+9dQiwmTkE4TloBNx8Kv9UiGARsr9Nba7IpcBRA1iTV9vwlYnrE3Uy6I7Aj6qLjQuqw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
@@ -890,8 +901,8 @@ packages:
|
||||
resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
caniuse-lite@1.0.30001752:
|
||||
resolution: {integrity: sha512-vKUk7beoukxE47P5gcVNKkDRzXdVofotshHwfR9vmpeFKxmI5PBpgOMC18LUJUA/DvJ70Y7RveasIBraqsyO/g==}
|
||||
caniuse-lite@1.0.30001759:
|
||||
resolution: {integrity: sha512-Pzfx9fOKoKvevQf8oCXoyNRQ5QyxJj+3O0Rqx2V5oxT61KGx8+n6hV/IUyJeifUci2clnmmKVpvtiqRzgiWjSw==}
|
||||
|
||||
ccount@2.0.1:
|
||||
resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==}
|
||||
@@ -1906,8 +1917,8 @@ packages:
|
||||
natural-compare@1.4.0:
|
||||
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
|
||||
|
||||
next@15.5.6:
|
||||
resolution: {integrity: sha512-zTxsnI3LQo3c9HSdSf91O1jMNsEzIXDShXd4wVdg9y5shwLqBXi4ZtUUJyB86KGVSJLZx0PFONvO54aheGX8QQ==}
|
||||
next@15.5.7:
|
||||
resolution: {integrity: sha512-+t2/0jIJ48kUpGKkdlhgkv+zPTEOoXyr60qXe68eB/pl3CMJaLeIGjzp5D6Oqt25hCBiBTt8wEeeAzfJvUKnPQ==}
|
||||
engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
@@ -2255,8 +2266,8 @@ packages:
|
||||
resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
sharp@0.34.4:
|
||||
resolution: {integrity: sha512-FUH39xp3SBPnxWvd5iib1X8XY7J0K0X7d93sie9CJg2PO8/7gmg89Nve6OjItK53/MlAushNNxteBYfM6DEuoA==}
|
||||
sharp@0.34.5:
|
||||
resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
|
||||
shebang-command@2.0.0:
|
||||
@@ -2718,7 +2729,7 @@ snapshots:
|
||||
tslib: 2.8.1
|
||||
optional: true
|
||||
|
||||
'@emnapi/runtime@1.6.0':
|
||||
'@emnapi/runtime@1.7.1':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
optional: true
|
||||
@@ -2864,90 +2875,98 @@ snapshots:
|
||||
'@img/colour@1.0.0':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-darwin-arm64@0.34.4':
|
||||
'@img/sharp-darwin-arm64@0.34.5':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-darwin-arm64': 1.2.3
|
||||
'@img/sharp-libvips-darwin-arm64': 1.2.4
|
||||
optional: true
|
||||
|
||||
'@img/sharp-darwin-x64@0.34.4':
|
||||
'@img/sharp-darwin-x64@0.34.5':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-darwin-x64': 1.2.3
|
||||
'@img/sharp-libvips-darwin-x64': 1.2.4
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-darwin-arm64@1.2.3':
|
||||
'@img/sharp-libvips-darwin-arm64@1.2.4':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-darwin-x64@1.2.3':
|
||||
'@img/sharp-libvips-darwin-x64@1.2.4':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-linux-arm64@1.2.3':
|
||||
'@img/sharp-libvips-linux-arm64@1.2.4':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-linux-arm@1.2.3':
|
||||
'@img/sharp-libvips-linux-arm@1.2.4':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-linux-ppc64@1.2.3':
|
||||
'@img/sharp-libvips-linux-ppc64@1.2.4':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-linux-s390x@1.2.3':
|
||||
'@img/sharp-libvips-linux-riscv64@1.2.4':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-linux-x64@1.2.3':
|
||||
'@img/sharp-libvips-linux-s390x@1.2.4':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-linuxmusl-arm64@1.2.3':
|
||||
'@img/sharp-libvips-linux-x64@1.2.4':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-linuxmusl-x64@1.2.3':
|
||||
'@img/sharp-libvips-linuxmusl-arm64@1.2.4':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-linux-arm64@0.34.4':
|
||||
'@img/sharp-libvips-linuxmusl-x64@1.2.4':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-linux-arm64@0.34.5':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-linux-arm64': 1.2.3
|
||||
'@img/sharp-libvips-linux-arm64': 1.2.4
|
||||
optional: true
|
||||
|
||||
'@img/sharp-linux-arm@0.34.4':
|
||||
'@img/sharp-linux-arm@0.34.5':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-linux-arm': 1.2.3
|
||||
'@img/sharp-libvips-linux-arm': 1.2.4
|
||||
optional: true
|
||||
|
||||
'@img/sharp-linux-ppc64@0.34.4':
|
||||
'@img/sharp-linux-ppc64@0.34.5':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-linux-ppc64': 1.2.3
|
||||
'@img/sharp-libvips-linux-ppc64': 1.2.4
|
||||
optional: true
|
||||
|
||||
'@img/sharp-linux-s390x@0.34.4':
|
||||
'@img/sharp-linux-riscv64@0.34.5':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-linux-s390x': 1.2.3
|
||||
'@img/sharp-libvips-linux-riscv64': 1.2.4
|
||||
optional: true
|
||||
|
||||
'@img/sharp-linux-x64@0.34.4':
|
||||
'@img/sharp-linux-s390x@0.34.5':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-linux-x64': 1.2.3
|
||||
'@img/sharp-libvips-linux-s390x': 1.2.4
|
||||
optional: true
|
||||
|
||||
'@img/sharp-linuxmusl-arm64@0.34.4':
|
||||
'@img/sharp-linux-x64@0.34.5':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-linuxmusl-arm64': 1.2.3
|
||||
'@img/sharp-libvips-linux-x64': 1.2.4
|
||||
optional: true
|
||||
|
||||
'@img/sharp-linuxmusl-x64@0.34.4':
|
||||
'@img/sharp-linuxmusl-arm64@0.34.5':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-linuxmusl-x64': 1.2.3
|
||||
'@img/sharp-libvips-linuxmusl-arm64': 1.2.4
|
||||
optional: true
|
||||
|
||||
'@img/sharp-wasm32@0.34.4':
|
||||
'@img/sharp-linuxmusl-x64@0.34.5':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-linuxmusl-x64': 1.2.4
|
||||
optional: true
|
||||
|
||||
'@img/sharp-wasm32@0.34.5':
|
||||
dependencies:
|
||||
'@emnapi/runtime': 1.6.0
|
||||
'@emnapi/runtime': 1.7.1
|
||||
optional: true
|
||||
|
||||
'@img/sharp-win32-arm64@0.34.4':
|
||||
'@img/sharp-win32-arm64@0.34.5':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-win32-ia32@0.34.4':
|
||||
'@img/sharp-win32-ia32@0.34.5':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-win32-x64@0.34.4':
|
||||
'@img/sharp-win32-x64@0.34.5':
|
||||
optional: true
|
||||
|
||||
'@isaacs/cliui@8.0.2':
|
||||
@@ -2976,38 +2995,38 @@ snapshots:
|
||||
'@napi-rs/wasm-runtime@0.2.12':
|
||||
dependencies:
|
||||
'@emnapi/core': 1.5.0
|
||||
'@emnapi/runtime': 1.6.0
|
||||
'@emnapi/runtime': 1.7.1
|
||||
'@tybys/wasm-util': 0.10.1
|
||||
optional: true
|
||||
|
||||
'@next/env@15.5.6': {}
|
||||
'@next/env@15.5.7': {}
|
||||
|
||||
'@next/eslint-plugin-next@14.2.33':
|
||||
dependencies:
|
||||
glob: 10.3.10
|
||||
|
||||
'@next/swc-darwin-arm64@15.5.6':
|
||||
'@next/swc-darwin-arm64@15.5.7':
|
||||
optional: true
|
||||
|
||||
'@next/swc-darwin-x64@15.5.6':
|
||||
'@next/swc-darwin-x64@15.5.7':
|
||||
optional: true
|
||||
|
||||
'@next/swc-linux-arm64-gnu@15.5.6':
|
||||
'@next/swc-linux-arm64-gnu@15.5.7':
|
||||
optional: true
|
||||
|
||||
'@next/swc-linux-arm64-musl@15.5.6':
|
||||
'@next/swc-linux-arm64-musl@15.5.7':
|
||||
optional: true
|
||||
|
||||
'@next/swc-linux-x64-gnu@15.5.6':
|
||||
'@next/swc-linux-x64-gnu@15.5.7':
|
||||
optional: true
|
||||
|
||||
'@next/swc-linux-x64-musl@15.5.6':
|
||||
'@next/swc-linux-x64-musl@15.5.7':
|
||||
optional: true
|
||||
|
||||
'@next/swc-win32-arm64-msvc@15.5.6':
|
||||
'@next/swc-win32-arm64-msvc@15.5.7':
|
||||
optional: true
|
||||
|
||||
'@next/swc-win32-x64-msvc@15.5.6':
|
||||
'@next/swc-win32-x64-msvc@15.5.7':
|
||||
optional: true
|
||||
|
||||
'@nodelib/fs.scandir@2.1.5':
|
||||
@@ -3445,7 +3464,7 @@ snapshots:
|
||||
|
||||
callsites@3.1.0: {}
|
||||
|
||||
caniuse-lite@1.0.30001752: {}
|
||||
caniuse-lite@1.0.30001759: {}
|
||||
|
||||
ccount@2.0.1: {}
|
||||
|
||||
@@ -4854,25 +4873,25 @@ snapshots:
|
||||
|
||||
natural-compare@1.4.0: {}
|
||||
|
||||
next@15.5.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
|
||||
next@15.5.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
|
||||
dependencies:
|
||||
'@next/env': 15.5.6
|
||||
'@next/env': 15.5.7
|
||||
'@swc/helpers': 0.5.15
|
||||
caniuse-lite: 1.0.30001752
|
||||
caniuse-lite: 1.0.30001759
|
||||
postcss: 8.4.31
|
||||
react: 18.3.1
|
||||
react-dom: 18.3.1(react@18.3.1)
|
||||
styled-jsx: 5.1.6(react@18.3.1)
|
||||
optionalDependencies:
|
||||
'@next/swc-darwin-arm64': 15.5.6
|
||||
'@next/swc-darwin-x64': 15.5.6
|
||||
'@next/swc-linux-arm64-gnu': 15.5.6
|
||||
'@next/swc-linux-arm64-musl': 15.5.6
|
||||
'@next/swc-linux-x64-gnu': 15.5.6
|
||||
'@next/swc-linux-x64-musl': 15.5.6
|
||||
'@next/swc-win32-arm64-msvc': 15.5.6
|
||||
'@next/swc-win32-x64-msvc': 15.5.6
|
||||
sharp: 0.34.4
|
||||
'@next/swc-darwin-arm64': 15.5.7
|
||||
'@next/swc-darwin-x64': 15.5.7
|
||||
'@next/swc-linux-arm64-gnu': 15.5.7
|
||||
'@next/swc-linux-arm64-musl': 15.5.7
|
||||
'@next/swc-linux-x64-gnu': 15.5.7
|
||||
'@next/swc-linux-x64-musl': 15.5.7
|
||||
'@next/swc-win32-arm64-msvc': 15.5.7
|
||||
'@next/swc-win32-x64-msvc': 15.5.7
|
||||
sharp: 0.34.5
|
||||
transitivePeerDependencies:
|
||||
- '@babel/core'
|
||||
- babel-plugin-macros
|
||||
@@ -5282,34 +5301,36 @@ snapshots:
|
||||
es-errors: 1.3.0
|
||||
es-object-atoms: 1.1.1
|
||||
|
||||
sharp@0.34.4:
|
||||
sharp@0.34.5:
|
||||
dependencies:
|
||||
'@img/colour': 1.0.0
|
||||
detect-libc: 2.1.2
|
||||
semver: 7.7.3
|
||||
optionalDependencies:
|
||||
'@img/sharp-darwin-arm64': 0.34.4
|
||||
'@img/sharp-darwin-x64': 0.34.4
|
||||
'@img/sharp-libvips-darwin-arm64': 1.2.3
|
||||
'@img/sharp-libvips-darwin-x64': 1.2.3
|
||||
'@img/sharp-libvips-linux-arm': 1.2.3
|
||||
'@img/sharp-libvips-linux-arm64': 1.2.3
|
||||
'@img/sharp-libvips-linux-ppc64': 1.2.3
|
||||
'@img/sharp-libvips-linux-s390x': 1.2.3
|
||||
'@img/sharp-libvips-linux-x64': 1.2.3
|
||||
'@img/sharp-libvips-linuxmusl-arm64': 1.2.3
|
||||
'@img/sharp-libvips-linuxmusl-x64': 1.2.3
|
||||
'@img/sharp-linux-arm': 0.34.4
|
||||
'@img/sharp-linux-arm64': 0.34.4
|
||||
'@img/sharp-linux-ppc64': 0.34.4
|
||||
'@img/sharp-linux-s390x': 0.34.4
|
||||
'@img/sharp-linux-x64': 0.34.4
|
||||
'@img/sharp-linuxmusl-arm64': 0.34.4
|
||||
'@img/sharp-linuxmusl-x64': 0.34.4
|
||||
'@img/sharp-wasm32': 0.34.4
|
||||
'@img/sharp-win32-arm64': 0.34.4
|
||||
'@img/sharp-win32-ia32': 0.34.4
|
||||
'@img/sharp-win32-x64': 0.34.4
|
||||
'@img/sharp-darwin-arm64': 0.34.5
|
||||
'@img/sharp-darwin-x64': 0.34.5
|
||||
'@img/sharp-libvips-darwin-arm64': 1.2.4
|
||||
'@img/sharp-libvips-darwin-x64': 1.2.4
|
||||
'@img/sharp-libvips-linux-arm': 1.2.4
|
||||
'@img/sharp-libvips-linux-arm64': 1.2.4
|
||||
'@img/sharp-libvips-linux-ppc64': 1.2.4
|
||||
'@img/sharp-libvips-linux-riscv64': 1.2.4
|
||||
'@img/sharp-libvips-linux-s390x': 1.2.4
|
||||
'@img/sharp-libvips-linux-x64': 1.2.4
|
||||
'@img/sharp-libvips-linuxmusl-arm64': 1.2.4
|
||||
'@img/sharp-libvips-linuxmusl-x64': 1.2.4
|
||||
'@img/sharp-linux-arm': 0.34.5
|
||||
'@img/sharp-linux-arm64': 0.34.5
|
||||
'@img/sharp-linux-ppc64': 0.34.5
|
||||
'@img/sharp-linux-riscv64': 0.34.5
|
||||
'@img/sharp-linux-s390x': 0.34.5
|
||||
'@img/sharp-linux-x64': 0.34.5
|
||||
'@img/sharp-linuxmusl-arm64': 0.34.5
|
||||
'@img/sharp-linuxmusl-x64': 0.34.5
|
||||
'@img/sharp-wasm32': 0.34.5
|
||||
'@img/sharp-win32-arm64': 0.34.5
|
||||
'@img/sharp-win32-ia32': 0.34.5
|
||||
'@img/sharp-win32-x64': 0.34.5
|
||||
optional: true
|
||||
|
||||
shebang-command@2.0.0:
|
||||
|
||||
@@ -145,6 +145,8 @@ type outExpecter struct {
|
||||
runeReader *bufio.Reader
|
||||
}
|
||||
|
||||
// Deprecated: use ExpectMatchContext instead.
|
||||
// This uses a background context, so will not respect the test's context.
|
||||
func (e *outExpecter) ExpectMatch(str string) string {
|
||||
return e.expectMatchContextFunc(str, e.ExpectMatchContext)
|
||||
}
|
||||
|
||||
+2
-2
@@ -93,11 +93,11 @@
|
||||
"lucide-react": "0.555.0",
|
||||
"monaco-editor": "0.55.1",
|
||||
"pretty-bytes": "6.1.1",
|
||||
"react": "19.2.0",
|
||||
"react": "19.2.1",
|
||||
"react-color": "2.19.3",
|
||||
"react-confetti": "6.4.0",
|
||||
"react-date-range": "1.4.0",
|
||||
"react-dom": "19.2.0",
|
||||
"react-dom": "19.2.1",
|
||||
"react-markdown": "9.1.0",
|
||||
"react-query": "npm:@tanstack/react-query@5.77.0",
|
||||
"react-resizable-panels": "3.0.6",
|
||||
|
||||
Generated
+516
-516
File diff suppressed because it is too large
Load Diff
Generated
+6
@@ -33,6 +33,12 @@ export interface AIBridgeConfig {
|
||||
readonly bedrock: AIBridgeBedrockConfig;
|
||||
readonly inject_coder_mcp_tools: boolean;
|
||||
readonly retention: number;
|
||||
/**
|
||||
* Overload protection settings.
|
||||
*/
|
||||
readonly max_concurrency: number;
|
||||
readonly rate_limit: number;
|
||||
readonly rate_window: number;
|
||||
}
|
||||
|
||||
// From codersdk/aibridge.go
|
||||
|
||||
@@ -24,6 +24,7 @@ const ALLOWED_EXTERNAL_APP_PROTOCOLS = [
|
||||
"jetbrains:",
|
||||
"kiro:",
|
||||
"positron:",
|
||||
"antigravity:",
|
||||
];
|
||||
|
||||
type GetVSCodeHrefParams = {
|
||||
|
||||
@@ -33,7 +33,7 @@ const meta: Meta<typeof BatchDeleteConfirmation> = {
|
||||
name: "task-docs-789",
|
||||
display_name: "Update Documentation",
|
||||
initial_prompt: "Update documentation for the new features",
|
||||
// Intentionally null to test that only 2 workspaces are shown in review resources stage
|
||||
// Intentionally null to test that only 2 workspaces are shown
|
||||
workspace_id: null,
|
||||
created_at: new Date(
|
||||
Date.now() - 3 * 24 * 60 * 60 * 1000,
|
||||
@@ -64,23 +64,3 @@ export const ReviewTasks: Story = {
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const ReviewResources: Story = {
|
||||
play: async ({ canvasElement, step }) => {
|
||||
const body = within(canvasElement.ownerDocument.body);
|
||||
|
||||
await step("Advance to stage 2: Review tasks", async () => {
|
||||
const confirmButton = await body.findByRole("button", {
|
||||
name: /review selected tasks/i,
|
||||
});
|
||||
await userEvent.click(confirmButton);
|
||||
});
|
||||
|
||||
await step("Advance to stage 3: Review resources", async () => {
|
||||
const confirmButton = await body.findByRole("button", {
|
||||
name: /confirm.*tasks/i,
|
||||
});
|
||||
await userEvent.click(confirmButton);
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { Task } from "api/typesGenerated";
|
||||
import { ConfirmDialog } from "components/Dialogs/ConfirmDialog/ConfirmDialog";
|
||||
import dayjs from "dayjs";
|
||||
import relativeTime from "dayjs/plugin/relativeTime";
|
||||
import { ClockIcon, ServerIcon, UserIcon } from "lucide-react";
|
||||
import { ClockIcon, UserIcon } from "lucide-react";
|
||||
import { type FC, type ReactNode, useState } from "react";
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
@@ -24,17 +24,12 @@ export const BatchDeleteConfirmation: FC<BatchDeleteConfirmationProps> = ({
|
||||
onConfirm,
|
||||
isLoading,
|
||||
}) => {
|
||||
const [stage, setStage] = useState<"consequences" | "tasks" | "resources">(
|
||||
"consequences",
|
||||
);
|
||||
const [stage, setStage] = useState<"consequences" | "tasks">("consequences");
|
||||
|
||||
const onProceed = () => {
|
||||
switch (stage) {
|
||||
case "resources":
|
||||
onConfirm();
|
||||
break;
|
||||
case "tasks":
|
||||
setStage("resources");
|
||||
onConfirm();
|
||||
break;
|
||||
case "consequences":
|
||||
setStage("tasks");
|
||||
@@ -45,15 +40,12 @@ export const BatchDeleteConfirmation: FC<BatchDeleteConfirmationProps> = ({
|
||||
const taskCount = `${checkedTasks.length} ${
|
||||
checkedTasks.length === 1 ? "task" : "tasks"
|
||||
}`;
|
||||
const workspaceCountText = `${workspaceCount} ${
|
||||
workspaceCount === 1 ? "workspace" : "workspaces"
|
||||
}`;
|
||||
|
||||
let confirmText: ReactNode = <>Review selected tasks…</>;
|
||||
if (stage === "tasks") {
|
||||
confirmText = <>Confirm {taskCount}…</>;
|
||||
}
|
||||
if (stage === "resources") {
|
||||
const workspaceCountText = `${workspaceCount} ${
|
||||
workspaceCount === 1 ? "workspace" : "workspaces"
|
||||
}`;
|
||||
confirmText = (
|
||||
<>
|
||||
Delete {taskCount} and {workspaceCountText}
|
||||
@@ -70,7 +62,6 @@ export const BatchDeleteConfirmation: FC<BatchDeleteConfirmationProps> = ({
|
||||
onClose();
|
||||
}}
|
||||
title={`Delete ${taskCount}`}
|
||||
hideCancel
|
||||
confirmLoading={isLoading}
|
||||
confirmText={confirmText}
|
||||
onConfirm={onProceed}
|
||||
@@ -78,11 +69,6 @@ export const BatchDeleteConfirmation: FC<BatchDeleteConfirmationProps> = ({
|
||||
<>
|
||||
{stage === "consequences" && <Consequences />}
|
||||
{stage === "tasks" && <Tasks tasks={checkedTasks} />}
|
||||
{stage === "resources" && (
|
||||
<Resources tasks={checkedTasks} workspaceCount={workspaceCount} />
|
||||
)}
|
||||
{/* Preload ServerIcon to prevent flicker on stage 3 */}
|
||||
<ServerIcon className="sr-only" aria-hidden />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
@@ -93,11 +79,6 @@ interface TasksStageProps {
|
||||
tasks: readonly Task[];
|
||||
}
|
||||
|
||||
interface ResourcesStageProps {
|
||||
tasks: readonly Task[];
|
||||
workspaceCount: number;
|
||||
}
|
||||
|
||||
const Consequences: FC = () => {
|
||||
return (
|
||||
<>
|
||||
@@ -132,11 +113,11 @@ const Tasks: FC<TasksStageProps> = ({ tasks }) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<ul className="list-none p-0 border border-solid border-zinc-200 dark:border-zinc-700 rounded-lg overflow-x-hidden overflow-y-auto max-h-[184px]">
|
||||
<ul className="list-none p-0 border border-solid border-border rounded-lg overflow-x-hidden overflow-y-auto max-h-48">
|
||||
{tasks.map((task) => (
|
||||
<li
|
||||
key={task.id}
|
||||
className="py-2 px-4 border-solid border-0 border-b border-zinc-200 dark:border-zinc-700 last:border-b-0"
|
||||
className="py-2 px-4 border-solid border-0 border-b border-border last:border-b-0"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-6">
|
||||
<span className="font-medium text-content-primary max-w-[400px] overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
@@ -174,24 +155,3 @@ const Tasks: FC<TasksStageProps> = ({ tasks }) => {
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const Resources: FC<ResourcesStageProps> = ({ tasks, workspaceCount }) => {
|
||||
const taskCount = tasks.length;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<p>
|
||||
Deleting {taskCount === 1 ? "this task" : "these tasks"} will also
|
||||
permanently destroy…
|
||||
</p>
|
||||
<div className="flex flex-wrap justify-center gap-x-5 gap-y-1.5 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<ServerIcon className="size-icon-sm" />
|
||||
<span>
|
||||
{workspaceCount} {workspaceCount === 1 ? "workspace" : "workspaces"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -47,16 +47,12 @@ export const AppStatuses: FC<AppStatusesProps> = ({
|
||||
referenceDate,
|
||||
}) => {
|
||||
const [displayStatuses, setDisplayStatuses] = useState(false);
|
||||
// Statuses are returned from the API sorted by created_at DESC, id DESC.
|
||||
const allStatuses: StatusWithAppInfo[] = agent.apps.flatMap((app) =>
|
||||
app.statuses
|
||||
.map((status) => ({
|
||||
...status,
|
||||
app,
|
||||
}))
|
||||
.sort(
|
||||
(a, b) =>
|
||||
new Date(b.created_at).getTime() - new Date(a.created_at).getTime(),
|
||||
),
|
||||
app.statuses.map((status) => ({
|
||||
...status,
|
||||
app,
|
||||
})),
|
||||
);
|
||||
|
||||
if (allStatuses.length === 0) {
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import { type Interpolation, type Theme, useTheme } from "@emotion/react";
|
||||
import { visuallyHidden } from "@mui/utils";
|
||||
import type { Workspace } from "api/typesGenerated";
|
||||
import { ConfirmDialog } from "components/Dialogs/ConfirmDialog/ConfirmDialog";
|
||||
import { ExternalImage } from "components/ExternalImage/ExternalImage";
|
||||
import { Stack } from "components/Stack/Stack";
|
||||
import dayjs from "dayjs";
|
||||
import relativeTime from "dayjs/plugin/relativeTime";
|
||||
import { ClockIcon, UserIcon } from "lucide-react";
|
||||
@@ -67,7 +64,7 @@ export const BatchDeleteConfirmation: FC<BatchDeleteConfirmationProps> = ({
|
||||
);
|
||||
}
|
||||
|
||||
// The flicker of these icons is quit noticeable if they aren't loaded in advance,
|
||||
// The flicker of these icons is quite noticeable if they aren't loaded in advance,
|
||||
// so we insert them into the document without actually displaying them yet.
|
||||
const resourceIconPreloads = [
|
||||
...new Set(
|
||||
@@ -78,7 +75,7 @@ export const BatchDeleteConfirmation: FC<BatchDeleteConfirmationProps> = ({
|
||||
),
|
||||
),
|
||||
].map((url) => (
|
||||
<img key={url} alt="" aria-hidden css={{ ...visuallyHidden }} src={url} />
|
||||
<img key={url} alt="" aria-hidden className="sr-only" src={url} />
|
||||
));
|
||||
|
||||
return (
|
||||
@@ -90,7 +87,6 @@ export const BatchDeleteConfirmation: FC<BatchDeleteConfirmationProps> = ({
|
||||
onClose();
|
||||
}}
|
||||
title={`Delete ${workspaceCount}`}
|
||||
hideCancel
|
||||
confirmLoading={isLoading}
|
||||
confirmText={confirmText}
|
||||
onConfirm={onProceed}
|
||||
@@ -118,7 +114,7 @@ const Consequences: FC = () => {
|
||||
return (
|
||||
<>
|
||||
<p>Deleting workspaces is irreversible!</p>
|
||||
<ul css={styles.consequences}>
|
||||
<ul className="flex flex-col gap-2 pl-4 mb-0">
|
||||
<li>
|
||||
Terraform resources belonging to deleted workspaces will be destroyed.
|
||||
</li>
|
||||
@@ -129,8 +125,6 @@ const Consequences: FC = () => {
|
||||
};
|
||||
|
||||
const Workspaces: FC<StageProps> = ({ workspaces }) => {
|
||||
const theme = useTheme();
|
||||
|
||||
const mostRecent = workspaces.reduce(
|
||||
(latestSoFar, against) => {
|
||||
if (!latestSoFar) {
|
||||
@@ -150,69 +144,47 @@ const Workspaces: FC<StageProps> = ({ workspaces }) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<ul css={styles.workspacesList}>
|
||||
<ul className="list-none p-0 border border-solid border-border rounded-lg overflow-x-hidden overflow-y-auto max-h-48">
|
||||
{workspaces.map((workspace) => (
|
||||
<li key={workspace.id} css={styles.workspace}>
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
justifyContent="space-between"
|
||||
spacing={3}
|
||||
>
|
||||
<span
|
||||
css={{ fontWeight: 500, color: theme.experimental.l1.text }}
|
||||
>
|
||||
<li
|
||||
key={workspace.id}
|
||||
className="py-2 px-4 border-solid border-0 border-b border-border last:border-b-0"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-6">
|
||||
<span className="font-medium text-content-primary max-w-[400px] overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
{workspace.name}
|
||||
</span>
|
||||
<Stack css={{ gap: 0, fontSize: 14 }} justifyContent="flex-end">
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
justifyContent="flex-end"
|
||||
spacing={1}
|
||||
>
|
||||
<span
|
||||
css={{ whiteSpace: "nowrap", textOverflow: "ellipsis" }}
|
||||
>
|
||||
|
||||
<div className="flex flex-col text-sm items-end">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="whitespace-nowrap">
|
||||
{workspace.owner_name}
|
||||
</span>
|
||||
<PersonIcon />
|
||||
</Stack>
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
spacing={1}
|
||||
justifyContent="flex-end"
|
||||
>
|
||||
<span
|
||||
css={{ whiteSpace: "nowrap", textOverflow: "ellipsis" }}
|
||||
>
|
||||
<UserIcon className="size-icon-sm -m-px" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="whitespace-nowrap">
|
||||
{dayjs(workspace.last_used_at).fromNow()}
|
||||
</span>
|
||||
<ClockIcon className="size-icon-xs" />
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<Stack
|
||||
justifyContent="center"
|
||||
direction="row"
|
||||
wrap="wrap"
|
||||
css={{ gap: "6px 20px", fontSize: 14 }}
|
||||
>
|
||||
<Stack direction="row" alignItems="center" spacing={1}>
|
||||
<PersonIcon />
|
||||
<div className="flex flex-wrap justify-center gap-x-5 gap-y-1.5 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<UserIcon className="size-icon-sm -m-px" />
|
||||
<span>{ownersCount}</span>
|
||||
</Stack>
|
||||
</div>
|
||||
{mostRecent && (
|
||||
<Stack direction="row" alignItems="center" spacing={1}>
|
||||
<div className="flex items-center gap-2">
|
||||
<ClockIcon className="size-icon-xs" />
|
||||
<span>Last used {dayjs(mostRecent.last_used_at).fromNow()}</span>
|
||||
</Stack>
|
||||
</div>
|
||||
)}
|
||||
</Stack>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -233,66 +205,22 @@ const Resources: FC<StageProps> = ({ workspaces }) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<div className="flex flex-col gap-4">
|
||||
<p>
|
||||
Deleting{" "}
|
||||
{workspaces.length === 1 ? "this workspace" : "these workspaces"} will
|
||||
also permanently destroy…
|
||||
</p>
|
||||
<Stack
|
||||
direction="row"
|
||||
justifyContent="center"
|
||||
wrap="wrap"
|
||||
css={{ gap: "6px 20px", fontSize: 14 }}
|
||||
>
|
||||
<div className="flex flex-wrap justify-center gap-x-5 gap-y-1.5 text-sm">
|
||||
{Object.entries(resources).map(([type, summary]) => (
|
||||
<Stack key={type} direction="row" alignItems="center" spacing={1}>
|
||||
<ExternalImage
|
||||
src={summary.icon}
|
||||
width={styles.summaryIcon.width}
|
||||
height={styles.summaryIcon.height}
|
||||
/>
|
||||
<div key={type} className="flex items-center gap-2">
|
||||
<ExternalImage src={summary.icon} width={16} height={16} />
|
||||
<span>
|
||||
{summary.count} <code>{type}</code>
|
||||
</span>
|
||||
</Stack>
|
||||
</div>
|
||||
))}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const PersonIcon: FC = () => {
|
||||
// Using the Lucide icon with appropriate size class
|
||||
return <UserIcon className="size-icon-sm" css={{ margin: -1 }} />;
|
||||
};
|
||||
|
||||
const styles = {
|
||||
summaryIcon: { width: 16, height: 16 },
|
||||
|
||||
consequences: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 8,
|
||||
paddingLeft: 16,
|
||||
marginBottom: 0,
|
||||
},
|
||||
|
||||
workspacesList: (theme) => ({
|
||||
listStyleType: "none",
|
||||
padding: 0,
|
||||
border: `1px solid ${theme.palette.divider}`,
|
||||
borderRadius: 8,
|
||||
overflow: "hidden auto",
|
||||
maxHeight: 184,
|
||||
}),
|
||||
|
||||
workspace: (theme) => ({
|
||||
padding: "8px 16px",
|
||||
borderBottom: `1px solid ${theme.palette.divider}`,
|
||||
|
||||
"&:last-child": {
|
||||
border: "none",
|
||||
},
|
||||
}),
|
||||
} satisfies Record<string, Interpolation<Theme>>;
|
||||
|
||||
@@ -146,7 +146,7 @@ export const WorkspacesPageView: FC<WorkspacesPageViewProps> = ({
|
||||
disabled={isRunningBatchAction}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
css={{ borderRadius: 9999, marginLeft: "auto" }}
|
||||
className="ml-auto"
|
||||
>
|
||||
Bulk actions
|
||||
<Spinner loading={isRunningBatchAction}>
|
||||
|
||||
+55
-34
@@ -24,16 +24,27 @@
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
:root {
|
||||
--color-white: #FFFFFF;
|
||||
--color-warning: #F3722C;
|
||||
--color-surface-primary: #090B0B;
|
||||
--color-zinc-900: #18181B;
|
||||
--color-zinc-400: #A1A1AA;
|
||||
--color-zinc-800: #27272A;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
background-color: #05060b;
|
||||
color: #a1a1aa;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: sans-serif;
|
||||
font-size: 16px;
|
||||
height: 100%;
|
||||
|
||||
background: var(--color-surface-primary);
|
||||
color: var(--color-zinc-400);
|
||||
|
||||
font-family: system-ui, sans-serif;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
header {
|
||||
@@ -41,37 +52,38 @@
|
||||
}
|
||||
|
||||
.container {
|
||||
--side-padding: 24px;
|
||||
--side-padding: 1.5rem;
|
||||
width: 100%;
|
||||
max-width: calc(500px + var(--side-padding) * 2);
|
||||
max-width: calc(31.25rem + var(--side-padding) * 2);
|
||||
padding: 0 var(--side-padding);
|
||||
}
|
||||
|
||||
.coder-svg {
|
||||
width: 80px;
|
||||
margin-bottom: 24px;
|
||||
width: 5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 24px;
|
||||
margin-bottom: 8px;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 400;
|
||||
color: white;
|
||||
color: var(--color-white);
|
||||
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
p,
|
||||
li {
|
||||
line-height: 140%;
|
||||
font-size: 14px;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
|
||||
.button-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
margin-top: 24px;
|
||||
gap: 0.75rem;
|
||||
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.button-group a,
|
||||
@@ -79,29 +91,37 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 6px 16px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #2c3854;
|
||||
text-decoration: none;
|
||||
|
||||
padding: 0.5rem 1rem;
|
||||
|
||||
border-radius: 0.375rem;
|
||||
border: 1px solid var(--color-zinc-800);
|
||||
background: none;
|
||||
font-size: inherit;
|
||||
color: inherit;
|
||||
width: 200px;
|
||||
height: 42px;
|
||||
|
||||
color: var(--color-white);
|
||||
text-decoration: none;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 400;
|
||||
|
||||
width: 12.5rem;
|
||||
height: 2.5rem;
|
||||
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.button-group a:hover,
|
||||
.button-group button:hover {
|
||||
border-color: hsl(222, 31%, 40%);
|
||||
background-color: var(--color-zinc-900);
|
||||
border-color: var(--color-zinc-800);
|
||||
}
|
||||
|
||||
.warning {
|
||||
margin-top: 24px;
|
||||
border: 1px solid rgb(243, 140, 89);
|
||||
background: rgb(13, 19, 33);
|
||||
border: 1px solid var(--color-warning);
|
||||
background: var(--color-zinc-900);
|
||||
width: 100%;
|
||||
padding: 24px;
|
||||
|
||||
margin: 1.5rem 0 0;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.warning-title {
|
||||
@@ -111,15 +131,16 @@
|
||||
}
|
||||
|
||||
.warning-title h3 {
|
||||
margin-left: 12px;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: white;
|
||||
font-size: 1rem;
|
||||
font-weight: 400;
|
||||
color: var(--color-white);
|
||||
|
||||
margin-left: 0.75rem;
|
||||
}
|
||||
|
||||
.warning li {
|
||||
padding-top: 10px;
|
||||
margin-left: 30px;
|
||||
padding-top: 0.625rem;
|
||||
margin-left: 1.875rem;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
Reference in New Issue
Block a user