@capybara-hq/kernel 0.1.0-rc.26 → 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.
- package/dist/app.js +2 -0
- package/dist/catalog/catalog-content.d.ts +18 -1
- package/dist/catalog/catalog-content.js +5 -2
- package/dist/catalog/catalog-scope.service.js +3 -2
- package/dist/catalog/marketplaces.service.d.ts +1 -1
- package/dist/catalog/org-catalog.service.js +13 -3
- package/dist/catalog/plugin-install.d.ts +37 -16
- package/dist/catalog/plugin-install.js +108 -34
- package/dist/catalog/plugin-upgrade.js +1 -0
- package/dist/channels/actions.d.ts +4 -1
- package/dist/channels/actions.js +5 -2
- package/dist/channels/cards.d.ts +7 -1
- package/dist/channels/cards.js +28 -1
- package/dist/channels/phrases.d.ts +15 -0
- package/dist/channels/phrases.js +9 -0
- package/dist/config/schema.d.ts +8 -0
- package/dist/config/schema.js +69 -0
- package/dist/conversations/events.d.ts +14 -0
- package/dist/conversations/events.js +20 -0
- package/dist/db/kernel-schema.d.ts +19 -4
- package/dist/errors.d.ts +5 -0
- package/dist/errors.js +5 -0
- package/dist/git/gateway.d.ts +93 -0
- package/dist/git/gateway.js +750 -0
- package/dist/git/gateway.route.d.ts +16 -0
- package/dist/git/gateway.route.js +41 -0
- package/dist/git/git.module.d.ts +10 -0
- package/dist/git/git.module.js +31 -0
- package/dist/git/identity.d.ts +80 -0
- package/dist/git/identity.js +209 -0
- package/dist/git/pkt-line.d.ts +53 -0
- package/dist/git/pkt-line.js +146 -0
- package/dist/git/run-end.d.ts +29 -0
- package/dist/git/run-end.js +121 -0
- package/dist/git/sources.d.ts +42 -0
- package/dist/git/sources.js +134 -0
- package/dist/git/tickets.d.ts +69 -0
- package/dist/git/tickets.js +194 -0
- package/dist/http/api.module.js +3 -0
- package/dist/http/app.d.ts +6 -0
- package/dist/http/app.js +3 -0
- package/dist/infra/config/kernel-config.service.js +2 -0
- package/dist/orgs/settings.service.d.ts +21 -0
- package/dist/orgs/settings.service.js +18 -0
- package/dist/overrides.d.ts +6 -0
- package/dist/overrides.js +4 -0
- package/dist/platform/exclusions.js +3 -0
- package/dist/runtime/run-finish.d.ts +5 -0
- package/dist/runtime/run-finish.js +12 -3
- package/dist/runtime/runtime.module.js +2 -0
- package/dist/runtime/waits.js +7 -5
- package/dist/schedules/fire.d.ts +3 -3
- package/dist/schedules/fire.js +7 -4
- package/dist/schedules/grant-rules.d.ts +40 -0
- package/dist/schedules/grant-rules.js +83 -0
- package/dist/schedules/scheduled-tasks.service.d.ts +9 -7
- package/dist/schedules/scheduled-tasks.service.js +50 -15
- package/dist/schedules/types.d.ts +2 -0
- package/dist/tools/approvals.service.d.ts +100 -19
- package/dist/tools/approvals.service.js +288 -21
- package/dist/tools/builtin-names.d.ts +4 -2
- package/dist/tools/builtin-names.js +6 -1
- package/dist/tools/builtins/schedules.d.ts +11 -9
- package/dist/tools/builtins/schedules.js +202 -44
- package/dist/tools/connector-account-policies.d.ts +8 -0
- package/dist/tools/connector-account-policies.js +8 -0
- package/dist/tools/connectors.controller.d.ts +2 -0
- package/dist/tools/connectors.controller.js +5 -1
- package/dist/tools/connectors.service.d.ts +46 -3
- package/dist/tools/connectors.service.js +98 -9
- package/dist/tools/credentials.d.ts +18 -0
- package/dist/tools/credentials.js +60 -0
- package/dist/tools/credentials.module.d.ts +7 -0
- package/dist/tools/credentials.module.js +22 -0
- package/dist/tools/git-asks.d.ts +113 -0
- package/dist/tools/git-asks.js +174 -0
- package/dist/tools/git-push-policy.d.ts +89 -0
- package/dist/tools/git-push-policy.js +175 -0
- package/dist/tools/info.d.ts +3 -3
- package/dist/tools/info.js +3 -3
- package/dist/tools/network-asks.d.ts +5 -0
- package/dist/tools/network-asks.js +9 -2
- package/dist/tools/tools.module.js +4 -3
- package/package.json +3 -3
- package/src/migrations/0126_git_gateway.sql +10 -0
- package/src/migrations/0127_git_push_policy.sql +12 -0
- package/src/migrations/0128_scheduled_task_plugin_disabled.sql +10 -0
- 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
|
+
}
|