@capybara-hq/kernel 0.1.0-rc.27 → 0.1.0-rc.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/dist/app.js +2 -0
  2. package/dist/catalog/catalog-content.d.ts +15 -3
  3. package/dist/catalog/catalog-content.js +5 -3
  4. package/dist/catalog/catalog-scope.service.js +3 -2
  5. package/dist/catalog/marketplaces.service.d.ts +1 -1
  6. package/dist/catalog/org-catalog.service.js +10 -3
  7. package/dist/catalog/plugin-install.d.ts +34 -18
  8. package/dist/catalog/plugin-install.js +84 -46
  9. package/dist/catalog/plugin-upgrade.js +1 -0
  10. package/dist/channels/actions.d.ts +4 -1
  11. package/dist/channels/actions.js +5 -2
  12. package/dist/channels/cards.d.ts +7 -1
  13. package/dist/channels/cards.js +28 -1
  14. package/dist/channels/phrases.d.ts +15 -0
  15. package/dist/channels/phrases.js +9 -0
  16. package/dist/config/schema.d.ts +8 -0
  17. package/dist/config/schema.js +69 -0
  18. package/dist/conversations/events.d.ts +14 -0
  19. package/dist/conversations/events.js +20 -0
  20. package/dist/db/kernel-schema.d.ts +19 -4
  21. package/dist/errors.d.ts +5 -0
  22. package/dist/errors.js +5 -0
  23. package/dist/git/gateway.d.ts +93 -0
  24. package/dist/git/gateway.js +750 -0
  25. package/dist/git/gateway.route.d.ts +16 -0
  26. package/dist/git/gateway.route.js +41 -0
  27. package/dist/git/git.module.d.ts +10 -0
  28. package/dist/git/git.module.js +31 -0
  29. package/dist/git/identity.d.ts +80 -0
  30. package/dist/git/identity.js +209 -0
  31. package/dist/git/pkt-line.d.ts +53 -0
  32. package/dist/git/pkt-line.js +146 -0
  33. package/dist/git/run-end.d.ts +29 -0
  34. package/dist/git/run-end.js +121 -0
  35. package/dist/git/sources.d.ts +42 -0
  36. package/dist/git/sources.js +134 -0
  37. package/dist/git/tickets.d.ts +69 -0
  38. package/dist/git/tickets.js +194 -0
  39. package/dist/http/api.module.js +3 -0
  40. package/dist/http/app.d.ts +6 -0
  41. package/dist/http/app.js +3 -0
  42. package/dist/infra/config/kernel-config.service.js +2 -0
  43. package/dist/orgs/settings.service.d.ts +21 -0
  44. package/dist/orgs/settings.service.js +18 -0
  45. package/dist/overrides.d.ts +6 -0
  46. package/dist/overrides.js +4 -0
  47. package/dist/platform/exclusions.js +3 -0
  48. package/dist/runtime/run-finish.d.ts +5 -0
  49. package/dist/runtime/run-finish.js +12 -3
  50. package/dist/runtime/runtime.module.js +2 -0
  51. package/dist/runtime/waits.js +7 -5
  52. package/dist/schedules/fire.d.ts +3 -3
  53. package/dist/schedules/fire.js +7 -4
  54. package/dist/schedules/grant-rules.d.ts +40 -0
  55. package/dist/schedules/grant-rules.js +83 -0
  56. package/dist/schedules/scheduled-tasks.service.d.ts +9 -7
  57. package/dist/schedules/scheduled-tasks.service.js +50 -15
  58. package/dist/schedules/types.d.ts +2 -0
  59. package/dist/tools/approvals.service.d.ts +100 -19
  60. package/dist/tools/approvals.service.js +288 -21
  61. package/dist/tools/builtin-names.d.ts +4 -2
  62. package/dist/tools/builtin-names.js +6 -1
  63. package/dist/tools/builtins/schedules.d.ts +11 -9
  64. package/dist/tools/builtins/schedules.js +202 -44
  65. package/dist/tools/connector-account-policies.d.ts +8 -0
  66. package/dist/tools/connector-account-policies.js +8 -0
  67. package/dist/tools/connectors.controller.d.ts +2 -0
  68. package/dist/tools/connectors.controller.js +5 -1
  69. package/dist/tools/connectors.service.d.ts +46 -3
  70. package/dist/tools/connectors.service.js +98 -5
  71. package/dist/tools/credentials.d.ts +18 -0
  72. package/dist/tools/credentials.js +60 -0
  73. package/dist/tools/credentials.module.d.ts +7 -0
  74. package/dist/tools/credentials.module.js +22 -0
  75. package/dist/tools/git-asks.d.ts +113 -0
  76. package/dist/tools/git-asks.js +174 -0
  77. package/dist/tools/git-push-policy.d.ts +89 -0
  78. package/dist/tools/git-push-policy.js +175 -0
  79. package/dist/tools/info.d.ts +3 -3
  80. package/dist/tools/info.js +3 -3
  81. package/dist/tools/network-asks.d.ts +5 -0
  82. package/dist/tools/network-asks.js +9 -2
  83. package/dist/tools/tools.module.js +4 -3
  84. package/package.json +3 -3
  85. package/src/migrations/0126_git_gateway.sql +10 -0
  86. package/src/migrations/0127_git_push_policy.sql +12 -0
  87. package/src/migrations/0128_scheduled_task_plugin_disabled.sql +10 -0
  88. package/src/migrations/0129_connector_plugin_disabled.sql +5 -0
@@ -0,0 +1,750 @@
1
+ var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
2
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
3
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
4
+ else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
5
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
6
+ };
7
+ var __param = (this && this.__param) || function (paramIndex, decorator) {
8
+ return function (target, key) { decorator(target, key, paramIndex); }
9
+ };
10
+ import { Readable, Transform } from "node:stream";
11
+ import { pipeline } from "node:stream/promises";
12
+ import { Inject, Injectable, Optional } from "@nestjs/common";
13
+ import { audit } from "../identity/audit.js";
14
+ import { KernelConfigService } from "../infra/config/kernel-config.service.js";
15
+ import { KernelDb } from "../infra/database/kernel-db.js";
16
+ import { KERNEL_LOGGER, REDIS } from "../infra/tokens.js";
17
+ import { NetDeniedError } from "../net/public-address.js";
18
+ import { safeFetch } from "../net/safe-fetch.js";
19
+ import { normalizeGitRepo } from "../schedules/grant-rules.js";
20
+ import { appendMissingGrant } from "../schedules/unattended.js";
21
+ import { CredentialMissing, Credentials } from "../tools/credentials.js";
22
+ import { gitAskedNote, gitNotConnectedNote, gitPolicyDeniedNote, gitUnattendedNote, recordGitAsk, } from "../tools/git-asks.js";
23
+ import { decideGitPush } from "../tools/git-push-policy.js";
24
+ import { gitNetPolicy } from "./identity.js";
25
+ import { advertisementError, ReceivePackParseError, readReceivePackCommands, receivePackRefusal, } from "./pkt-line.js";
26
+ import { recordGitFetch } from "./run-end.js";
27
+ import { GitSources } from "./sources.js";
28
+ import { GitTickets, hashGitTicket } from "./tickets.js";
29
+ /**
30
+ * The git gateway (sandbox git credentials spec §5): relays git smart-HTTP from a sandbox to a git
31
+ * host, authenticated by a run ticket, adding the actor's credential on the way out. Stateless
32
+ * across replicas: the ticket, the source, the push policy, the byte and concurrency counters are
33
+ * read from Redis / PG on every request; only the request's own idle timer lives in the process.
34
+ *
35
+ * The route (`/git/v1/<host>/<owner>/<repo>[.git]/<endpoint>`, {@link GIT_GATEWAY_PREFIX}) is a raw
36
+ * Fastify route (`gateway.route.ts`), like the channel webhook: git sends no CSRF header and the
37
+ * ticket is not an API credential, so neither the `AuthGuard` nor the API's error envelope apply.
38
+ * Everything git shows is plain text or pkt-lines in English.
39
+ */
40
+ /** The route prefix. */
41
+ export const GIT_GATEWAY_PREFIX = "/git/v1/";
42
+ /**
43
+ * Test seam (`KernelAppOptions.overrides`): the upstream origin of a git host (default
44
+ * `https://<host>`), e.g. a loopback fake for `github.com`. Production validation is unchanged:
45
+ * the fake is reached through the same `safeFetch` rules (loopback http only under
46
+ * `tools.oauthAllowInsecureLoopback`).
47
+ */
48
+ export const GIT_UPSTREAM_ORIGIN = Symbol("GIT_UPSTREAM_ORIGIN");
49
+ const HOST_RE = /^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z][a-z0-9-]{0,62}$/;
50
+ const SEGMENT_RE = /^[A-Za-z0-9_.-]{1,100}$/;
51
+ /**
52
+ * Parses `/git/v1/<host>/<owner>/<repo>[.git]/<endpoint>` with its query: GET `info/refs` with
53
+ * `service=git-upload-pack|git-receive-pack` and nothing else in the query, or POST
54
+ * `git-upload-pack` / `git-receive-pack` without a query. Anything else (dumb HTTP, other paths,
55
+ * other methods) is null (404).
56
+ */
57
+ export function parseGitGatewayPath(method, url) {
58
+ const q = url.indexOf("?");
59
+ const pathname = q >= 0 ? url.slice(0, q) : url;
60
+ const query = q >= 0 ? url.slice(q + 1) : "";
61
+ if (!pathname.startsWith(GIT_GATEWAY_PREFIX))
62
+ return null;
63
+ const parts = pathname.slice(GIT_GATEWAY_PREFIX.length).split("/");
64
+ let endpoint;
65
+ let service;
66
+ let head;
67
+ if (parts.length === 5 && parts[3] === "info" && parts[4] === "refs") {
68
+ if (method !== "GET")
69
+ return null;
70
+ const params = new URLSearchParams(query);
71
+ const svc = params.get("service");
72
+ if ([...params.keys()].length !== 1 ||
73
+ (svc !== "git-upload-pack" && svc !== "git-receive-pack")) {
74
+ return null;
75
+ }
76
+ endpoint = "info/refs";
77
+ service = svc;
78
+ head = parts.slice(0, 3);
79
+ }
80
+ else if (parts.length === 4 &&
81
+ (parts[3] === "git-upload-pack" || parts[3] === "git-receive-pack")) {
82
+ if (method !== "POST" || query !== "")
83
+ return null;
84
+ endpoint = parts[3];
85
+ service = parts[3];
86
+ head = parts.slice(0, 3);
87
+ }
88
+ else {
89
+ return null;
90
+ }
91
+ const [host, owner, name] = head;
92
+ if (host === undefined || owner === undefined || name === undefined)
93
+ return null;
94
+ if (!HOST_RE.test(host))
95
+ return null;
96
+ if (!SEGMENT_RE.test(owner) || !SEGMENT_RE.test(name))
97
+ return null;
98
+ const bare = name.replace(/\.git$/i, "");
99
+ // one `.git` suffix only: `app.git.git` would be decided as `app` but requested as `app.git`
100
+ if (/\.git$/i.test(bare))
101
+ return null;
102
+ const repo = normalizeGitRepo(`${owner}/${bare}`);
103
+ if (repo === null)
104
+ return null;
105
+ return { host, path: `${owner}/${bare}`, repo, endpoint, service };
106
+ }
107
+ /**
108
+ * Whether the ticket's repository allowlist admits `repo` (normalized). Entries are
109
+ * `<owner>/<repo>`, `<owner>/*` or `*`, compared case-insensitively; no list admits every one.
110
+ */
111
+ export function repoAllowed(repos, repo) {
112
+ if (repos === undefined)
113
+ return true;
114
+ return repos.some((entry) => {
115
+ const e = entry
116
+ .trim()
117
+ .toLowerCase()
118
+ .replace(/\.git$/, "");
119
+ if (e === "*")
120
+ return true;
121
+ if (e.endsWith("/*"))
122
+ return repo.startsWith(e.slice(0, -1));
123
+ return e === repo;
124
+ });
125
+ }
126
+ /** Request headers relayed upstream (spec §5.3); `user-agent` is suffixed. */
127
+ const FORWARD_REQUEST = ["content-type", "content-encoding", "accept", "git-protocol"];
128
+ /**
129
+ * Response headers relayed downstream. `content-encoding` is not among them: the fetch below
130
+ * decodes compressed answers, so the bytes sent on are identity-encoded.
131
+ */
132
+ const FORWARD_RESPONSE = ["content-type", "cache-control", "expires", "pragma"];
133
+ /** The Redis counter of a run's relayed bytes. */
134
+ export const gitBytesKey = (runId) => `git-bytes:${runId}`;
135
+ /** The Redis counter of an org's open gateway requests. */
136
+ export const gitConcurrencyKey = (orgId) => `git-conc:${orgId}`;
137
+ /** The byte counter lives a day after the run's last transfer (runs end long before). */
138
+ const BYTES_TTL_SECONDS = 24 * 60 * 60;
139
+ /**
140
+ * The concurrency counter's TTL, refreshed by every request: a replica that died holding slots
141
+ * frees them at the latest this long after the org's last request.
142
+ */
143
+ const CONCURRENCY_TTL_SECONDS = 15 * 60;
144
+ /** Bytes counted locally before they are added to the Redis counter. */
145
+ const BYTES_FLUSH = 256 * 1024;
146
+ /** Texts git prints (`remote: …`). */
147
+ const TICKET_REFUSED = "Capybara: the git ticket is not valid (expired, revoked, or the task has ended). Run the command again from the agent's task.";
148
+ const TOO_MANY = "Capybara: too many git transfers are running in this organization; try again shortly.";
149
+ const BYTES_EXCEEDED = "Capybara: this task reached its git transfer limit (tools.git.maxBytesPerRun).";
150
+ const reconnect = (host) => `Capybara: ${host} refused the connection's credential. Reconnect it under Settings → Connectors, then run the command again.`;
151
+ const repoRefused = (repo) => `Capybara: this task may not use the repository ${repo}.`;
152
+ const upstreamFailed = (host, detail) => `Capybara: the request to ${host} failed (${detail}).`;
153
+ const BAD_PUSH = (why) => `Capybara: the push could not be read (${why}).`;
154
+ const notConnected = (host) => `Capybara: you have no connection that can reach ${host}. Connect one under Settings → Connectors, then run the command again.`;
155
+ /** The bytes of one request, added to the run's Redis counter in batches. */
156
+ class ByteMeter {
157
+ redis;
158
+ key;
159
+ cap;
160
+ slotKey;
161
+ pending = 0;
162
+ total;
163
+ /** Bytes this request moved (both ways). */
164
+ moved = 0;
165
+ /** The run went over its cap during this request. */
166
+ exceeded = false;
167
+ constructor(redis, key, cap, start,
168
+ /** The org's concurrency key: every flush refreshes its TTL, so a long transfer stays counted. */
169
+ slotKey) {
170
+ this.redis = redis;
171
+ this.key = key;
172
+ this.cap = cap;
173
+ this.slotKey = slotKey;
174
+ this.total = start;
175
+ }
176
+ /** Counts `n` bytes; rejects once the run is over its cap. */
177
+ async add(n) {
178
+ this.pending += n;
179
+ this.moved += n;
180
+ if (this.pending >= BYTES_FLUSH)
181
+ await this.flush();
182
+ else if (this.total + this.pending > this.cap)
183
+ await this.flush();
184
+ }
185
+ /** Adds the pending bytes to the counter; rejects when the run is over its cap. */
186
+ async flush() {
187
+ if (this.pending === 0)
188
+ return;
189
+ const n = this.pending;
190
+ this.pending = 0;
191
+ const res = await this.redis
192
+ .multi()
193
+ .incrby(this.key, n)
194
+ .expire(this.key, BYTES_TTL_SECONDS)
195
+ .expire(this.slotKey, CONCURRENCY_TTL_SECONDS)
196
+ .exec();
197
+ const v = res?.[0]?.[1];
198
+ this.total = typeof v === "number" ? v : this.total + n;
199
+ if (this.total > this.cap) {
200
+ this.exceeded = true;
201
+ throw new GitBytesExceeded();
202
+ }
203
+ }
204
+ }
205
+ class GitBytesExceeded extends Error {
206
+ constructor() {
207
+ super("git transfer limit reached");
208
+ this.name = "GitBytesExceeded";
209
+ }
210
+ }
211
+ /**
212
+ * The guards of one authenticated request, from the moment it holds an org slot: the idle timer
213
+ * (any byte either way resets it; when it fires, {@link ac} aborts, which cuts the upstream call
214
+ * and destroys the request), the byte meter, and the slot itself, held until the response is over
215
+ * (the drain of a refused push's pack happens before the answer). In-flight transfers are
216
+ * not tracked by `GracefulShutdown`: a replica that stops cuts them with its connections, and the
217
+ * slots they held fall back to the counter's TTL.
218
+ */
219
+ class Transfer {
220
+ meter;
221
+ idleMs;
222
+ release;
223
+ ac = new AbortController();
224
+ idle;
225
+ open = true;
226
+ constructor(meter, idleMs, release) {
227
+ this.meter = meter;
228
+ this.idleMs = idleMs;
229
+ this.release = release;
230
+ }
231
+ /** Resets the idle timer (a no-op once the transfer is over). */
232
+ touch = () => {
233
+ if (this.idle !== undefined)
234
+ clearTimeout(this.idle);
235
+ this.idle = undefined;
236
+ if (!this.open)
237
+ return;
238
+ this.idle = setTimeout(() => this.ac.abort(new Error("idle")), this.idleMs);
239
+ };
240
+ /** The response is over: stops the timer, flushes the meter and frees the slot (once). */
241
+ drop() {
242
+ if (!this.open)
243
+ return;
244
+ this.open = false;
245
+ if (this.idle !== undefined)
246
+ clearTimeout(this.idle);
247
+ this.idle = undefined;
248
+ void this.meter.flush().catch(() => { });
249
+ this.release();
250
+ }
251
+ }
252
+ /** A pass-through that counts bytes and resets the idle timer (backpressure kept). */
253
+ function meterStream(meter, touch) {
254
+ return new Transform({
255
+ transform(chunk, _enc, done) {
256
+ touch();
257
+ meter.add(chunk.length).then(() => done(null, chunk), (e) => done(e));
258
+ },
259
+ flush(done) {
260
+ meter.flush().then(() => done(), (e) => done(e));
261
+ },
262
+ });
263
+ }
264
+ let GitGateway = class GitGateway {
265
+ tickets;
266
+ sources;
267
+ credentials;
268
+ db;
269
+ redis;
270
+ config;
271
+ log;
272
+ upstreamOrigin;
273
+ constructor(tickets, sources, credentials, db, redis,
274
+ /** Read per request: `tools.git.*` is read live. */
275
+ config, log, upstreamOrigin) {
276
+ this.tickets = tickets;
277
+ this.sources = sources;
278
+ this.credentials = credentials;
279
+ this.db = db;
280
+ this.redis = redis;
281
+ this.config = config;
282
+ this.log = log;
283
+ this.upstreamOrigin = upstreamOrigin;
284
+ }
285
+ /** Handles one gateway request (the raw route's handler). */
286
+ async handle(req, reply) {
287
+ const started = performance.now();
288
+ const fields = {};
289
+ const body = req.body instanceof Readable ? req.body : undefined;
290
+ // a refusal closes the connection and stops reading the body: nothing unauthenticated or
291
+ // refused is read further, and nothing it sends is left unbounded
292
+ const text = (status, message) => {
293
+ fields.status = status;
294
+ if (body !== undefined)
295
+ reply.raw.once("finish", () => body.destroy());
296
+ return reply
297
+ .code(status)
298
+ .header("content-type", "text/plain; charset=utf-8")
299
+ .header("cache-control", "no-cache")
300
+ .header("connection", "close")
301
+ .send(`${message}\n`);
302
+ };
303
+ const target = parseGitGatewayPath(req.method, req.url);
304
+ const wrongBody = req.method === "POST"
305
+ ? contentTypeOf(req) !== `application/x-${target?.service}-request`
306
+ : body !== undefined;
307
+ if (target === null || wrongBody) {
308
+ return text(404, "Capybara: not a git smart-HTTP endpoint.");
309
+ }
310
+ fields.host = target.host;
311
+ fields.repo = target.repo;
312
+ fields.service = target.service;
313
+ const raw = /^Bearer\s+(\S+)$/i.exec(String(req.headers.authorization ?? ""))?.[1];
314
+ if (raw !== undefined)
315
+ fields.ticket = hashGitTicket(raw).slice(0, 8);
316
+ const binding = raw === undefined ? null : await this.tickets.resolve(raw);
317
+ // 403, not 401: on a 401 git asks for a username and never prints the message
318
+ if (binding === null)
319
+ return this.done(text(403, TICKET_REFUSED), fields, started);
320
+ if (!binding.hosts.map((h) => h.toLowerCase()).includes(target.host)) {
321
+ return this.done(text(404, "Capybara: not a git smart-HTTP endpoint."), fields, started);
322
+ }
323
+ if (!repoAllowed(binding.repos, target.repo)) {
324
+ return this.done(text(403, repoRefused(target.repo)), fields, started);
325
+ }
326
+ // the source is looked up on every request (the ticket's hosts are only an upper bound)
327
+ const source = (await this.sources.sourcesFor(binding.orgId, binding.accountId)).find((s) => s.host === target.host);
328
+ if (source === undefined) {
329
+ const message = target.service === "git-receive-pack"
330
+ ? `Capybara: ${gitNotConnectedNote(target.host)}`
331
+ : notConnected(target.host);
332
+ return this.done(text(403, message), fields, started);
333
+ }
334
+ fields.connectorId = source.connectorId;
335
+ const cfg = this.config.get().tools.git;
336
+ const used = Number((await this.redis.get(gitBytesKey(binding.runId))) ?? 0);
337
+ if (used >= cfg.maxBytesPerRun)
338
+ return this.done(text(403, BYTES_EXCEEDED), fields, started);
339
+ const { key: slotKey, inUse, release } = await this.acquireOrgSlot(binding.orgId);
340
+ if (inUse > cfg.maxConcurrentPerOrg) {
341
+ release();
342
+ return this.done(text(429, TOO_MANY), fields, started);
343
+ }
344
+ // from here the slot is held: every byte of the body is counted and capped, and the idle
345
+ // timer runs, before anything else reads it (the push command parse included)
346
+ const transfer = new Transfer(new ByteMeter(this.redis, gitBytesKey(binding.runId), cfg.maxBytesPerRun, used, slotKey), cfg.idleTimeoutMs, release);
347
+ reply.raw.once("close", () => {
348
+ if (!reply.raw.writableFinished)
349
+ transfer.ac.abort(new Error("client closed"));
350
+ transfer.drop();
351
+ });
352
+ transfer.ac.signal.addEventListener("abort", () => body?.destroy(), { once: true });
353
+ transfer.touch();
354
+ let metered;
355
+ if (body !== undefined) {
356
+ const t = meterStream(transfer.meter, transfer.touch);
357
+ // a cap or idle error destroys the request too
358
+ pipeline(body, t).catch(() => { });
359
+ metered = t;
360
+ }
361
+ const push = { refs: [] };
362
+ try {
363
+ await this.relay(req, reply, {
364
+ target,
365
+ binding,
366
+ source,
367
+ body: metered,
368
+ fields,
369
+ text,
370
+ transfer,
371
+ push,
372
+ });
373
+ }
374
+ catch (e) {
375
+ // anything unexpected before the answer started is a 502 git prints; after, the
376
+ // connection is cut (git reports the transfer broke)
377
+ this.log.warn("git gateway: the relay failed", {
378
+ ...fields,
379
+ error: e instanceof Error ? e.name : "unknown",
380
+ });
381
+ if (!reply.sent && !reply.raw.headersSent) {
382
+ if (transfer.meter.exceeded)
383
+ text(403, BYTES_EXCEEDED);
384
+ else if (transfer.ac.signal.aborted) {
385
+ text(504, upstreamFailed(target.host, "no data within the idle timeout"));
386
+ }
387
+ else
388
+ text(502, upstreamFailed(target.host, "gateway error"));
389
+ }
390
+ else
391
+ reply.raw.destroy();
392
+ }
393
+ finally {
394
+ fields.bytes = transfer.meter.moved;
395
+ // best effort: an aborted request's bytes are counted too
396
+ await transfer.meter.flush().catch(() => { });
397
+ this.done(undefined, fields, started);
398
+ // audit and counters never block or fail the transfer: written after the response is over
399
+ void this.afterRequest(binding, source, target, push, transfer.meter.moved);
400
+ }
401
+ }
402
+ /** The `git.push` audit row of a receive-pack and the run's fetch counters (best effort). */
403
+ async afterRequest(binding, source, target, push, bytes) {
404
+ try {
405
+ if (target.service === "git-upload-pack") {
406
+ await recordGitFetch(this.redis, binding.runId, target.host, target.repo, bytes, target.endpoint === "git-upload-pack");
407
+ }
408
+ if (push.result === undefined)
409
+ return;
410
+ const run = await this.db.db
411
+ .selectFrom("runs")
412
+ .select("conversation_id")
413
+ .where("id", "=", binding.runId)
414
+ .executeTakeFirst();
415
+ await audit(this.db.db, {
416
+ actorAccountId: binding.accountId,
417
+ orgId: binding.orgId,
418
+ action: "git.push",
419
+ target: `${target.host}/${target.repo}`,
420
+ // never a token or a ticket
421
+ detail: {
422
+ runId: binding.runId,
423
+ ...(run !== undefined && { conversationId: run.conversation_id }),
424
+ connectorId: source.connectorId,
425
+ host: target.host,
426
+ repo: target.repo,
427
+ refs: push.refs,
428
+ result: push.result,
429
+ bot: source.bot,
430
+ },
431
+ });
432
+ }
433
+ catch (e) {
434
+ this.log.warn("git gateway: the audit write failed", {
435
+ host: target.host,
436
+ repo: target.repo,
437
+ error: e instanceof Error ? e.name : "unknown",
438
+ });
439
+ }
440
+ }
441
+ /**
442
+ * Takes one of the org's gateway slots (Redis `git-conc:<orgId>`, TTL refreshed by every
443
+ * request and every byte meter flush) and returns how many are in use now with the slot's
444
+ * release (idempotent; a counter that went below zero after an expiry is reset).
445
+ */
446
+ async acquireOrgSlot(orgId) {
447
+ const key = gitConcurrencyKey(orgId);
448
+ const res = await this.redis.multi().incr(key).expire(key, CONCURRENCY_TTL_SECONDS).exec();
449
+ let released = false;
450
+ const release = () => {
451
+ if (released)
452
+ return;
453
+ released = true;
454
+ void this.redis
455
+ .decr(key)
456
+ .then((n) => (n < 0 ? this.redis.del(key) : 0))
457
+ .catch(() => { });
458
+ };
459
+ const n = res?.[0]?.[1];
460
+ // a failed count is taken as full: refuse rather than run unbounded
461
+ return { key, inUse: typeof n === "number" ? n : Number.POSITIVE_INFINITY, release };
462
+ }
463
+ done(result, fields, started) {
464
+ if (fields.logged !== true) {
465
+ fields.logged = true;
466
+ const { logged: _l, ...rest } = fields;
467
+ this.log.info("git gateway request", {
468
+ ...rest,
469
+ ms: Math.round(performance.now() - started),
470
+ });
471
+ }
472
+ return result;
473
+ }
474
+ async relay(req, reply, c) {
475
+ const { target, binding, source, fields, text, transfer, push } = c;
476
+ /** Sets the audit result of a push that was read (a no-op for any other request). */
477
+ const pushResult = (r) => {
478
+ if (push.result !== undefined)
479
+ push.result = r;
480
+ };
481
+ const { meter, touch, ac } = transfer;
482
+ let upstreamBody = c.body;
483
+ // pushes: the policy decides before any byte reaches the host
484
+ if (target.service === "git-receive-pack") {
485
+ if (target.endpoint === "info/refs") {
486
+ const decision = await this.decide(binding, source, target);
487
+ if (decision.decision === "deny") {
488
+ await this.recordRefusal(binding, source, target, [], decision);
489
+ push.result = `refused:${decision.reason}`;
490
+ fields.status = 200;
491
+ fields.push = decision.reason;
492
+ reply
493
+ .code(200)
494
+ .header("content-type", "application/x-git-receive-pack-advertisement")
495
+ .header("cache-control", "no-cache")
496
+ .send(advertisementError(`Capybara: ${refusalNote(target.repo, [], decision)}`));
497
+ return;
498
+ }
499
+ }
500
+ else {
501
+ if (c.body === undefined) {
502
+ text(400, BAD_PUSH("no request body"));
503
+ return;
504
+ }
505
+ const encoding = String(req.headers["content-encoding"] ?? "identity").toLowerCase();
506
+ if (encoding !== "identity") {
507
+ // the gateway must read the ref updates before it decides
508
+ text(415, BAD_PUSH("compressed push requests are not supported"));
509
+ return;
510
+ }
511
+ const iterator = c.body[Symbol.asyncIterator]();
512
+ let parsed;
513
+ try {
514
+ parsed = await readReceivePackCommands(iterator);
515
+ }
516
+ catch (e) {
517
+ if (e instanceof ReceivePackParseError) {
518
+ text(400, BAD_PUSH(e.message));
519
+ return;
520
+ }
521
+ // the cap or the idle timer cut the body: handled by the caller
522
+ throw e;
523
+ }
524
+ const refs = parsed.commands.map((cmd) => cmd.ref);
525
+ push.refs = refs;
526
+ // a flush-only request updates nothing: not an attempt, not an error
527
+ push.result = refs.length === 0 ? "empty" : "error";
528
+ if (refs.length > 0) {
529
+ const decision = await this.decide(binding, source, target);
530
+ fields.push = decision.reason;
531
+ if (decision.decision !== "allow") {
532
+ await this.recordRefusal(binding, source, target, refs, decision);
533
+ push.result = `refused:${decision.reason}`;
534
+ const note = refusalNote(target.repo, refs, decision);
535
+ fields.status = 200;
536
+ // the refused pack is read and dropped before the answer (counted, capped, under the
537
+ // idle timer, the slot held): Node stops reading a request once its response has
538
+ // ended, and git reads the answer only after it has sent the body. A cap or idle
539
+ // error here cuts the connection (handled by the caller).
540
+ for (;;)
541
+ if ((await iterator.next()).done)
542
+ break;
543
+ reply
544
+ .code(200)
545
+ .header("content-type", "application/x-git-receive-pack-result")
546
+ .header("cache-control", "no-cache")
547
+ .send(receivePackRefusal(parsed.capabilities, refs, `Capybara: ${note}`, decision.decision === "ask" ? "needs approval" : "not allowed"));
548
+ return;
549
+ }
550
+ }
551
+ // the bytes already read go first, then the rest of the body as it arrives
552
+ upstreamBody = Readable.from((async function* () {
553
+ yield parsed.prefix;
554
+ for (;;) {
555
+ const next = await iterator.next();
556
+ if (next.done)
557
+ return;
558
+ yield next.value;
559
+ }
560
+ })(), { objectMode: false });
561
+ }
562
+ }
563
+ const url = `${(this.upstreamOrigin ?? defaultOrigin)(target.host)}/${target.path}.git/${target.endpoint}${target.endpoint === "info/refs" ? `?service=${target.service}` : ""}`;
564
+ const headers = new Headers();
565
+ for (const name of FORWARD_REQUEST) {
566
+ const v = req.headers[name];
567
+ if (typeof v === "string")
568
+ headers.set(name, v);
569
+ }
570
+ const ua = req.headers["user-agent"];
571
+ headers.set("user-agent", `${typeof ua === "string" ? `${ua.slice(0, 200)} ` : ""}capybara-git-gateway`);
572
+ const auth = await this.connectorAuth(binding.orgId, source.connectorId);
573
+ if (auth === null) {
574
+ pushResult("refused:not_connected");
575
+ text(403, notConnected(target.host));
576
+ return;
577
+ }
578
+ const authorization = () => this.credentials.gitAuthorization(binding.orgId, source.connectorId, auth, binding.accountId, source.username);
579
+ // a body is sent once (POST); a GET has none, so the 401 retry below never re-reads one
580
+ let unsent = upstreamBody;
581
+ const send = async (value) => {
582
+ headers.set("authorization", value);
583
+ const body = unsent === undefined ? undefined : Readable.toWeb(unsent);
584
+ unsent = undefined;
585
+ return safeFetch(url, {
586
+ method: req.method,
587
+ headers,
588
+ ...(body !== undefined && { body, duplex: "half" }),
589
+ signal: ac.signal,
590
+ },
591
+ // the same rules as the commit identity lookup: the deployment's egress proxy and its
592
+ // private host allowance (`marketplaces.allowPrivateHosts`, e.g. an internal GitHub
593
+ // Enterprise host), never a redirect
594
+ gitNetPolicy(this.config.get()));
595
+ };
596
+ let res;
597
+ let value = "";
598
+ try {
599
+ value = await authorization();
600
+ res = await send(value);
601
+ if (res.status === 401 && req.method === "GET") {
602
+ // one forced refresh and one retry; a POST body is not replayable
603
+ await res.body?.cancel().catch(() => { });
604
+ await this.credentials.gitUnauthorized(binding.orgId, source.connectorId, auth, binding.accountId, value);
605
+ value = await authorization();
606
+ res = await send(value);
607
+ fields.refreshed = true;
608
+ }
609
+ }
610
+ catch (e) {
611
+ if (e instanceof CredentialMissing) {
612
+ pushResult("refused:credential");
613
+ text(403, reconnect(target.host));
614
+ return;
615
+ }
616
+ if (e instanceof NetDeniedError) {
617
+ fields.net = e.code;
618
+ text(502, upstreamFailed(target.host, e.code === "net.redirect"
619
+ ? "it answered with a redirect, which is not followed"
620
+ : "refused by the network rules"));
621
+ return;
622
+ }
623
+ if (e instanceof GitBytesExceeded || meter.exceeded) {
624
+ pushResult("refused:byte_cap");
625
+ text(403, BYTES_EXCEEDED);
626
+ return;
627
+ }
628
+ if (ac.signal.aborted) {
629
+ text(504, upstreamFailed(target.host, "no data within the idle timeout"));
630
+ return;
631
+ }
632
+ throw e;
633
+ }
634
+ fields.upstream = res.status;
635
+ if (res.status !== 200)
636
+ pushResult(`upstream:${res.status}`);
637
+ if (res.status === 401) {
638
+ await res.body?.cancel().catch(() => { });
639
+ if (req.method === "POST") {
640
+ // a refused POST credential is refreshed for the next command (no retry here)
641
+ await this.credentials
642
+ .gitUnauthorized(binding.orgId, source.connectorId, auth, binding.accountId, value)
643
+ .catch(() => { });
644
+ }
645
+ text(403, reconnect(target.host));
646
+ return;
647
+ }
648
+ if (res.status !== 200) {
649
+ await res.body?.cancel().catch(() => { });
650
+ if (res.status === 404 || res.status === 403) {
651
+ text(res.status, `Capybara: ${target.host} answered ${res.status} for ${target.repo}.`);
652
+ }
653
+ else {
654
+ text(502, upstreamFailed(target.host, `status ${res.status}`));
655
+ }
656
+ return;
657
+ }
658
+ reply.code(200);
659
+ for (const name of FORWARD_RESPONSE) {
660
+ const v = res.headers.get(name);
661
+ if (v !== null)
662
+ reply.header(name, v);
663
+ }
664
+ fields.status = 200;
665
+ if (res.body === null) {
666
+ reply.send();
667
+ return;
668
+ }
669
+ const down = meterStream(meter, touch);
670
+ const source$ = Readable.fromWeb(res.body);
671
+ const done = pipeline(source$, down).catch((e) => {
672
+ fields.broken = e instanceof Error ? e.name : "unknown";
673
+ });
674
+ reply.send(down);
675
+ await done;
676
+ // `ok`: the host took the pack (its per-ref report is not parsed); a cut stream is an error
677
+ if (fields.broken === undefined && push.result !== "empty")
678
+ pushResult("ok");
679
+ }
680
+ /** The push decision for this target (spec §7). */
681
+ decide(binding, source, target) {
682
+ return decideGitPush(this.db.db, this.config, {
683
+ orgId: binding.orgId,
684
+ accountId: binding.accountId,
685
+ runId: binding.runId,
686
+ connectorId: source.connectorId,
687
+ host: target.host,
688
+ repo: target.repo,
689
+ });
690
+ }
691
+ /**
692
+ * Records a refused push for the exec (`git-ask:<execId>`, read by the Bash pipeline after the
693
+ * command) and, for an unattended run without a task grant, the missing grant on the task run.
694
+ */
695
+ async recordRefusal(binding, source, target, refs, decision) {
696
+ const outcome = decision.decision === "ask"
697
+ ? "ask"
698
+ : decision.reason === "unattended"
699
+ ? "unattended"
700
+ : "denied";
701
+ await recordGitAsk(this.redis, binding.execId, {
702
+ host: target.host,
703
+ repo: target.repo,
704
+ refs,
705
+ connectorId: source.connectorId,
706
+ ...(outcome !== "ask" && { outcome }),
707
+ });
708
+ if (decision.missingGrant !== undefined) {
709
+ await appendMissingGrant(this.db.db, decision.missingGrant.taskRunId, decision.missingGrant.key);
710
+ }
711
+ }
712
+ /** The connector's auth (it may have changed since the source was chosen); null when gone. */
713
+ async connectorAuth(orgId, connectorId) {
714
+ const row = await this.db.db
715
+ .selectFrom("connectors")
716
+ .select("auth")
717
+ .where("id", "=", connectorId)
718
+ .where("org_id", "=", orgId)
719
+ .where("deleted_at", "is", null)
720
+ .where("status", "=", "active")
721
+ .executeTakeFirst();
722
+ return row === undefined ? null : row.auth;
723
+ }
724
+ };
725
+ GitGateway = __decorate([
726
+ Injectable(),
727
+ __param(0, Inject(GitTickets)),
728
+ __param(1, Inject(GitSources)),
729
+ __param(2, Inject(Credentials)),
730
+ __param(3, Inject(KernelDb)),
731
+ __param(4, Inject(REDIS)),
732
+ __param(5, Inject(KernelConfigService)),
733
+ __param(6, Inject(KERNEL_LOGGER)),
734
+ __param(7, Optional()),
735
+ __param(7, Inject(GIT_UPSTREAM_ORIGIN))
736
+ ], GitGateway);
737
+ export { GitGateway };
738
+ const defaultOrigin = (host) => `https://${host}`;
739
+ const contentTypeOf = (req) => String(req.headers["content-type"] ?? "")
740
+ .split(";")[0]
741
+ ?.trim()
742
+ .toLowerCase() ?? "";
743
+ /** The note a refused push carries (the model reads it in the command's output). */
744
+ function refusalNote(repo, refs, d) {
745
+ if (d.decision === "ask")
746
+ return gitAskedNote(repo);
747
+ if (d.reason === "unattended")
748
+ return gitUnattendedNote(repo, refs);
749
+ return gitPolicyDeniedNote(repo, refs);
750
+ }