forked from coder/coder
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathcontext.go
More file actions
429 lines (399 loc) · 16.8 KB
/
Copy pathcontext.go
File metadata and controls
429 lines (399 loc) · 16.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
package agentapi
import (
"context"
"database/sql"
"errors"
"math"
"sort"
"time"
"github.com/google/uuid"
"golang.org/x/xerrors"
"google.golang.org/protobuf/encoding/protojson"
"google.golang.org/protobuf/proto"
"cdr.dev/slog/v3"
agentproto "github.com/coder/coder/v2/agent/proto"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbtime"
"github.com/coder/quartz"
)
// Server-side caps on a single PushContextState request. The agent
// enforces its own caps (64KiB per resource payload, 2MiB aggregate,
// 500 resources; see agent/agentcontext/resolve.go), but coderd
// cannot trust a workspace process, so pushes are re-validated here
// with headroom above the agent caps:
//
// - maxContextResourcesPerPush allows excluded stub entries past
// the agent's 500-resource cap.
// - maxContextResourceBodyBytes covers protojson and base64
// expansion of a 64KiB payload.
// - maxContextAggregateBodyBytes matches the 4MiB DRPC message
// cap so the invariant survives transport changes.
// - The string and hash caps bound the remaining row columns;
// source doubles as a btree primary key column, which PostgreSQL
// limits to roughly 2704 bytes per index entry.
const (
maxContextResourcesPerPush = 1000
maxContextResourceBodyBytes = 256 * 1024
maxContextAggregateBodyBytes = 4 * 1024 * 1024
maxContextSourceBytes = 1024
maxContextErrorBytes = 4096
maxContextHashBytes = 64
)
// ContextAPI implements the v2.10 PushContextState RPC. It persists
// the latest pushed snapshot per workspace agent across two tables
// (workspace_agent_context_snapshots and
// workspace_agent_context_resources) so later phases can hydrate
// chats and surface drift to the dashboard.
//
// The handler is a pure write path: nothing else in coderd reads
// these rows yet. If a bug here returns errors the agent's RunPush
// loop backs off and the workspace keeps behaving exactly like it
// did before v2.10.
type ContextAPI struct {
AgentID uuid.UUID
// Workspace caches workspace fields for the duration of the agent
// connection so dbauthz can authorize against the workspace RBAC
// object without re-fetching the workspace on every push.
Workspace *CachedWorkspaceFields
Log slog.Logger
Clock quartz.Clock
Database database.Store
// DirtyMarker hydrates chats from, and marks chats dirty against, the
// snapshot persisted by a push. It is nil when chatd is not running,
// in which case PushContextState stays a pure write path.
DirtyMarker ContextDirtyMarker
}
// ContextDirtyMarker hydrates chats from, and marks chats dirty against, a
// freshly persisted agent context snapshot. It is implemented by chatd and
// injected at coderd construction so this package neither imports the chat
// domain nor performs chat-authorized writes directly.
type ContextDirtyMarker interface {
// HydrateAndMarkChatsDirty runs inside the PushContextState
// transaction using the supplied store. It hydrates chats for the
// agent that have no pinned hash yet (no dirty event) and flips
// already-pinned chats whose hash differs from aggregateHash. It
// returns a callback that publishes the resulting dirty watch events;
// the caller invokes it only after the transaction commits. The
// callback is a no-op when nothing transitioned to dirty.
HydrateAndMarkChatsDirty(ctx context.Context, tx database.Store, agentID uuid.UUID, aggregateHash []byte, snapshotError string, now time.Time) (publishDirty func(), err error)
}
// PushContextState persists a snapshot pushed by the workspace
// agent. The transaction upserts the snapshot row, upserts each
// resource, then deletes any resources whose source is not in the
// incoming set so the stored snapshot and resource table always
// agree. It runs at repeatable read isolation (with retries) so two
// concurrent pushes cannot interleave their writes; the loser of the
// conflict re-runs the version gate against the winner's committed
// state.
//
// Returns accepted = false (without writing) when the push is a
// replay or out-of-order resend: the agent's per-process version
// counter is monotonic, and only an initial = true push from a
// freshly-booted agent resets that baseline. Replays and stale
// retransmits leave the stored state untouched.
//
// Authorization happens in dbauthz: every query in the transaction
// authorizes the actor (the agent's token subject) against the
// workspace that owns the agent.
func (a *ContextAPI) PushContextState(ctx context.Context, req *agentproto.PushContextStateRequest) (*agentproto.PushContextStateResponse, error) {
if req == nil {
return nil, xerrors.New("agentapi: PushContextState request is nil")
}
if err := validateContextPushRequest(req); err != nil {
return nil, err
}
rows, err := validateAndConvertContextResources(req.Resources)
if err != nil {
return nil, err
}
// Attach the cached workspace RBAC object so dbauthz can take its
// fast path. On failure (or when unset, e.g. prebuilds) dbauthz
// falls back to fetching the workspace by agent ID.
if a.Workspace != nil {
injected, err := a.Workspace.ContextInject(ctx)
if err != nil {
a.Log.Debug(ctx, "failed to inject cached workspace RBAC object", slog.Error(err))
} else {
ctx = injected
}
}
clock := a.Clock
if clock == nil {
clock = quartz.NewReal()
}
now := dbtime.Time(clock.Now())
activeSources := make([]string, 0, len(rows))
for _, r := range rows {
activeSources = append(activeSources, r.Source)
}
sort.Strings(activeSources)
var accepted bool
// publishDirty is captured from the final (committed) attempt and
// invoked after the transaction commits; ReadModifyUpdate may re-run
// the closure on serialization conflicts.
var publishDirty func()
err = database.ReadModifyUpdate(a.Database, func(tx database.Store) error {
// The closure re-runs on serialization conflicts; reset any
// state carried over from a rolled-back attempt.
accepted = false
publishDirty = nil
existing, err := tx.GetLatestWorkspaceAgentContextSnapshot(ctx, a.AgentID)
switch {
case errors.Is(err, sql.ErrNoRows):
// No previous snapshot; first push always wins.
case err != nil:
return xerrors.Errorf("get latest snapshot: %w", err)
default:
// Accept either a fresh agent process (initial) or
// a strictly newer version. Out-of-order or replayed
// pushes leave the stored state untouched.
//
//nolint:gosec // existing.Version is a uint64 round-tripped via BIGINT; non-negative by construction.
if !req.Initial && req.Version <= uint64(existing.Version) {
return nil
}
}
_, err = tx.UpsertWorkspaceAgentContextSnapshot(ctx, database.UpsertWorkspaceAgentContextSnapshotParams{
WorkspaceAgentID: a.AgentID,
//nolint:gosec // Bounded by validateContextPushRequest.
Version: int64(req.Version),
AggregateHash: append([]byte(nil), req.AggregateHash...),
SnapshotError: req.SnapshotError,
ReceivedAt: now,
})
if err != nil {
return xerrors.Errorf("upsert snapshot: %w", err)
}
for _, r := range rows {
r.WorkspaceAgentID = a.AgentID
r.Now = now
_, err = tx.UpsertWorkspaceAgentContextResource(ctx, r)
if err != nil {
return xerrors.Errorf("upsert resource %q: %w", r.Source, err)
}
}
err = tx.DeleteStaleWorkspaceAgentContextResources(ctx, database.DeleteStaleWorkspaceAgentContextResourcesParams{
WorkspaceAgentID: a.AgentID,
ActiveSources: activeSources,
})
if err != nil {
return xerrors.Errorf("delete stale resources: %w", err)
}
// Hydrate and dirty chats against the snapshot just written, in the
// same transaction so a concurrent refresh cannot interleave with
// the version gate. Events are published only after commit.
if a.DirtyMarker != nil {
publishDirty, err = a.DirtyMarker.HydrateAndMarkChatsDirty(ctx, tx, a.AgentID, req.AggregateHash, req.SnapshotError, now)
if err != nil {
return xerrors.Errorf("hydrate and mark chats dirty: %w", err)
}
}
accepted = true
return nil
})
if err != nil {
return nil, err
}
if !accepted {
a.Log.Debug(ctx, "PushContextState dropped: replay or out-of-order",
slog.F("agent_id", a.AgentID),
slog.F("version", req.Version),
slog.F("initial", req.Initial),
)
return &agentproto.PushContextStateResponse{Accepted: false}, nil
}
// The snapshot committed; fan out dirty watch events to chats whose
// pinned context drifted from this push.
if publishDirty != nil {
publishDirty()
}
a.Log.Debug(ctx, "PushContextState accepted",
slog.F("agent_id", a.AgentID),
slog.F("version", req.Version),
slog.F("initial", req.Initial),
slog.F("resources", len(rows)),
)
return &agentproto.PushContextStateResponse{Accepted: true}, nil
}
// validateContextPushRequest enforces the request-level caps: counts
// and sizes a compromised workspace could otherwise inflate to DoS
// coderd or bloat the database.
func validateContextPushRequest(req *agentproto.PushContextStateRequest) error {
if req.Version > math.MaxInt64 {
return xerrors.Errorf("agentapi: PushContextState version %d exceeds int64 range", req.Version)
}
if len(req.AggregateHash) > maxContextHashBytes {
return xerrors.Errorf("agentapi: PushContextState aggregate hash is %d bytes, exceeds %d byte cap", len(req.AggregateHash), maxContextHashBytes)
}
if len(req.SnapshotError) > maxContextErrorBytes {
return xerrors.Errorf("agentapi: PushContextState snapshot error is %d bytes, exceeds %d byte cap", len(req.SnapshotError), maxContextErrorBytes)
}
if len(req.Resources) > maxContextResourcesPerPush {
return xerrors.Errorf("agentapi: PushContextState has %d resources, exceeds %d resource cap", len(req.Resources), maxContextResourcesPerPush)
}
return nil
}
// validateAndConvertContextResources translates wire resources into
// upsert parameters while rejecting structurally invalid input:
//
// - empty, oversized, or duplicate sources (the PK depends on
// uniqueness and indexes the source column),
// - unknown body variants (kept extensible by emitting the proto's
// reserved kinds via dedicated body messages),
// - unknown status enum values,
// - per-resource and aggregate body sizes past the server caps.
//
// Validation is deliberately strict here so a misbehaving agent
// cannot poison the snapshot table. Phase 2 readers can then trust
// that every row maps to a known proto variant.
//
// WorkspaceAgentID and Now are left unset; the caller fills them at
// upsert time.
func validateAndConvertContextResources(resources []*agentproto.ContextResource) ([]database.UpsertWorkspaceAgentContextResourceParams, error) {
rows := make([]database.UpsertWorkspaceAgentContextResourceParams, 0, len(resources))
seen := make(map[string]struct{}, len(resources))
aggregateBodyBytes := 0
for i, r := range resources {
if r == nil {
return nil, xerrors.Errorf("agentapi: PushContextState resource at index %d is nil", i)
}
if r.Source == "" {
return nil, xerrors.Errorf("agentapi: PushContextState resource at index %d has empty source", i)
}
if len(r.Source) > maxContextSourceBytes {
return nil, xerrors.Errorf("agentapi: PushContextState resource at index %d has %d byte source, exceeds %d byte cap", i, len(r.Source), maxContextSourceBytes)
}
if _, ok := seen[r.Source]; ok {
return nil, xerrors.Errorf("agentapi: PushContextState duplicate source %q", r.Source)
}
seen[r.Source] = struct{}{}
if len(r.GetSourcePath()) > maxContextSourceBytes {
return nil, xerrors.Errorf("resource %q: source path is %d bytes, exceeds %d byte cap", r.Source, len(r.GetSourcePath()), maxContextSourceBytes)
}
if len(r.Error) > maxContextErrorBytes {
return nil, xerrors.Errorf("resource %q: error is %d bytes, exceeds %d byte cap", r.Source, len(r.Error), maxContextErrorBytes)
}
if len(r.ContentHash) > maxContextHashBytes {
return nil, xerrors.Errorf("resource %q: content hash is %d bytes, exceeds %d byte cap", r.Source, len(r.ContentHash), maxContextHashBytes)
}
if r.SizeBytes > math.MaxInt64 {
return nil, xerrors.Errorf("resource %q: size %d exceeds int64 range", r.Source, r.SizeBytes)
}
kind, body, err := marshalContextResourceBody(r)
if err != nil {
return nil, xerrors.Errorf("resource %q: %w", r.Source, err)
}
if len(body) > maxContextResourceBodyBytes {
return nil, xerrors.Errorf("resource %q: body is %d bytes, exceeds %d byte cap", r.Source, len(body), maxContextResourceBodyBytes)
}
aggregateBodyBytes += len(body)
if aggregateBodyBytes > maxContextAggregateBodyBytes {
return nil, xerrors.Errorf("agentapi: PushContextState aggregate body size exceeds %d byte cap", maxContextAggregateBodyBytes)
}
status, err := contextResourceStatus(r.Status)
if err != nil {
return nil, xerrors.Errorf("resource %q: %w", r.Source, err)
}
//nolint:exhaustruct // WorkspaceAgentID and Now are filled by the caller at upsert time.
rows = append(rows, database.UpsertWorkspaceAgentContextResourceParams{
Source: r.Source,
SourcePath: r.GetSourcePath(),
BodyKind: kind,
Body: body,
ContentHash: append([]byte(nil), r.ContentHash...),
//nolint:gosec // Bounded above.
SizeBytes: int64(r.SizeBytes),
Status: status,
Error: r.Error,
})
}
return rows, nil
}
// marshalContextResourceBody picks the body variant set on the wire
// resource and returns the (body_kind, body_jsonb) pair stored in
// the resource row. The body is protojson encoded so the schema can
// be evolved by adding fields to the proto without coderd changes,
// and a future reader can round-trip back to the proto type by
// switching on body_kind.
//
// Body is always populated, even on non-OK statuses: the wire
// guarantees the oneof variant is set so coderd can still attribute
// the failure to a known kind. For variants with no content fields
// (mcp_config), an empty JSON object is stored.
func marshalContextResourceBody(r *agentproto.ContextResource) (kind database.WorkspaceAgentContextBodyKind, body []byte, err error) {
switch b := r.Body.(type) {
case *agentproto.ContextResource_InstructionFile:
payload := b.InstructionFile
if payload == nil {
payload = &agentproto.InstructionFileBody{}
}
body, err = marshalBody(payload)
return database.WorkspaceAgentContextBodyKindInstructionFile, body, err
case *agentproto.ContextResource_Skill:
payload := b.Skill
if payload == nil {
payload = &agentproto.SkillMetaBody{}
}
body, err = marshalBody(payload)
return database.WorkspaceAgentContextBodyKindSkill, body, err
case *agentproto.ContextResource_McpConfig:
payload := b.McpConfig
if payload == nil {
payload = &agentproto.MCPConfigBody{}
}
body, err = marshalBody(payload)
return database.WorkspaceAgentContextBodyKindMcpConfig, body, err
case *agentproto.ContextResource_McpServer:
payload := b.McpServer
if payload == nil {
payload = &agentproto.MCPServerBody{}
}
body, err = marshalBody(payload)
return database.WorkspaceAgentContextBodyKindMcpServer, body, err
case nil:
return "", nil, xerrors.Errorf("missing body variant; status %s requires a typed body", r.Status)
default:
return "", nil, xerrors.Errorf("unsupported body variant %T", r.Body)
}
}
// contextBodyMarshalOptions produces deterministic-ish JSON for the
// body so the stored value compares equal across pushes that yield
// equivalent protos. Strict canonicalization (RFC 8785) is not
// required here; the enum column plus the protojson round trip give
// us a stable enough store.
var contextBodyMarshalOptions = protojson.MarshalOptions{
UseProtoNames: true,
EmitUnpopulated: false,
}
// marshalBody is a small wrapper around protojson.Marshal that
// keeps the body encoding in one place; future phases that read
// these rows mirror the call with protojson.Unmarshal into the
// matching proto.Message.
func marshalBody(msg proto.Message) ([]byte, error) {
out, err := contextBodyMarshalOptions.Marshal(msg)
if err != nil {
return nil, xerrors.Errorf("marshal body: %w", err)
}
return out, nil
}
// contextResourceStatus translates the wire status enum to the
// database enum. STATUS_UNSPECIFIED is rejected: every well-formed
// snapshot row needs an explicit status so cache invalidation, dirty
// fan-out, and the Sources drawer can reason about partial pushes
// deterministically.
func contextResourceStatus(s agentproto.ContextResource_Status) (database.WorkspaceAgentContextResourceStatus, error) {
switch s {
case agentproto.ContextResource_OK:
return database.WorkspaceAgentContextResourceStatusOk, nil
case agentproto.ContextResource_OVERSIZE:
return database.WorkspaceAgentContextResourceStatusOversize, nil
case agentproto.ContextResource_UNREADABLE:
return database.WorkspaceAgentContextResourceStatusUnreadable, nil
case agentproto.ContextResource_INVALID:
return database.WorkspaceAgentContextResourceStatusInvalid, nil
case agentproto.ContextResource_EXCLUDED:
return database.WorkspaceAgentContextResourceStatusExcluded, nil
default:
return "", xerrors.Errorf("unknown status %d", s)
}
}