@cldmv/gh-broker 0.9.0 → 0.10.1
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/README.md +17 -13
- package/config.example.json +69 -2
- package/config.sample.json +1 -1
- package/deploy/README.md +58 -19
- package/deploy/cli.mjs +38 -0
- package/deploy/gh-broker.service +9 -2
- package/deploy/install.mjs +99 -42
- package/deploy/lib/agent-cli.mjs +166 -0
- package/deploy/lib/agents.mjs +128 -0
- package/deploy/lib/cli.mjs +72 -0
- package/deploy/lib/config-merge.mjs +29 -8
- package/deploy/lib/install-args.mjs +3 -2
- package/deploy/lib/mcp-config.mjs +211 -0
- package/deploy/lib/otp-cli.mjs +136 -0
- package/deploy/lib/otp.mjs +325 -0
- package/deploy/lib/secret-store.mjs +1 -0
- package/deploy/lib/system.mjs +24 -12
- package/deploy/real-deps.mjs +102 -0
- package/dist/index.mjs +970 -71
- package/dist/index.mjs.map +4 -4
- package/package.json +4 -3
package/dist/index.mjs
CHANGED
|
@@ -7405,6 +7405,7 @@ var require_content_type = __commonJS({
|
|
|
7405
7405
|
|
|
7406
7406
|
// src/config.ts
|
|
7407
7407
|
import { readFileSync } from "node:fs";
|
|
7408
|
+
import { isAbsolute as isAbsolute3 } from "node:path";
|
|
7408
7409
|
|
|
7409
7410
|
// src/verbs/ghcr/config.ts
|
|
7410
7411
|
import { isAbsolute, normalize } from "node:path";
|
|
@@ -7473,6 +7474,184 @@ function imageAllowed(owner2, image2) {
|
|
|
7473
7474
|
return owner2.images.some((g) => imageGlobToRegExp(g).test(image2));
|
|
7474
7475
|
}
|
|
7475
7476
|
|
|
7477
|
+
// src/otp/totp.ts
|
|
7478
|
+
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
7479
|
+
var BASE32_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
|
7480
|
+
var STEP_SECONDS = 30;
|
|
7481
|
+
function base32Decode(text) {
|
|
7482
|
+
const clean = text.toUpperCase().replace(/=+$/, "");
|
|
7483
|
+
const out = [];
|
|
7484
|
+
let bits = 0;
|
|
7485
|
+
let value = 0;
|
|
7486
|
+
for (const ch of clean) {
|
|
7487
|
+
const idx = BASE32_ALPHABET.indexOf(ch);
|
|
7488
|
+
if (idx < 0) throw new Error("base32: invalid character");
|
|
7489
|
+
value = value << 5 | idx;
|
|
7490
|
+
bits += 5;
|
|
7491
|
+
if (bits >= 8) {
|
|
7492
|
+
out.push(value >>> bits - 8 & 255);
|
|
7493
|
+
bits -= 8;
|
|
7494
|
+
}
|
|
7495
|
+
}
|
|
7496
|
+
return Buffer.from(out);
|
|
7497
|
+
}
|
|
7498
|
+
function hotp(key2, counter, digits = 6) {
|
|
7499
|
+
const msg = Buffer.alloc(8);
|
|
7500
|
+
msg.writeBigUInt64BE(BigInt(counter));
|
|
7501
|
+
const mac2 = createHmac("sha1", key2).update(msg).digest();
|
|
7502
|
+
const offset = mac2[mac2.length - 1] & 15;
|
|
7503
|
+
const bin = (mac2[offset] & 127) << 24 | mac2[offset + 1] << 16 | mac2[offset + 2] << 8 | mac2[offset + 3];
|
|
7504
|
+
return String(bin % 10 ** digits).padStart(digits, "0");
|
|
7505
|
+
}
|
|
7506
|
+
function stepAt(nowMs) {
|
|
7507
|
+
return Math.floor(nowMs / 1e3 / STEP_SECONDS);
|
|
7508
|
+
}
|
|
7509
|
+
function safeEqual(a, b) {
|
|
7510
|
+
const x = Buffer.from(a);
|
|
7511
|
+
const y = Buffer.from(b);
|
|
7512
|
+
return x.length === y.length && timingSafeEqual(x, y);
|
|
7513
|
+
}
|
|
7514
|
+
function matchStep(key2, code, nowMs, skew = 1, digits = 6) {
|
|
7515
|
+
if (!new RegExp(`^\\d{${digits}}$`).test(code)) return void 0;
|
|
7516
|
+
const now = stepAt(nowMs);
|
|
7517
|
+
let matched;
|
|
7518
|
+
for (let step = now - skew; step <= now + skew; step++) {
|
|
7519
|
+
if (safeEqual(hotp(key2, step, digits), code)) matched = step;
|
|
7520
|
+
}
|
|
7521
|
+
return matched;
|
|
7522
|
+
}
|
|
7523
|
+
|
|
7524
|
+
// src/otp/policy.ts
|
|
7525
|
+
var OTP_STATUS = "otp_status";
|
|
7526
|
+
var OTP_ARG = "otp";
|
|
7527
|
+
var DEFAULT_GATED = /* @__PURE__ */ new Set([
|
|
7528
|
+
"create_repository",
|
|
7529
|
+
"fork_repository",
|
|
7530
|
+
"merge_pull_request",
|
|
7531
|
+
"pull_request_review_write",
|
|
7532
|
+
"actions_run_trigger",
|
|
7533
|
+
"label_write",
|
|
7534
|
+
"delete_branch",
|
|
7535
|
+
"dismiss_alert",
|
|
7536
|
+
// Disabled by default too (src/availability.ts); gated once enabled.
|
|
7537
|
+
"create_pull_request_with_copilot",
|
|
7538
|
+
"assign_copilot_to_issue",
|
|
7539
|
+
"assign_copilot_to_issue_with_intent"
|
|
7540
|
+
]);
|
|
7541
|
+
var OTP_DEFAULTS = {
|
|
7542
|
+
windowSeconds: 300,
|
|
7543
|
+
maxFailures: 5,
|
|
7544
|
+
lockoutSeconds: 600,
|
|
7545
|
+
maxLockouts: 3,
|
|
7546
|
+
statePath: "/var/lib/gh-broker/otp-state.json"
|
|
7547
|
+
};
|
|
7548
|
+
function otpSettings(config2) {
|
|
7549
|
+
const o = config2.otp;
|
|
7550
|
+
return {
|
|
7551
|
+
windowSeconds: o?.windowSeconds ?? OTP_DEFAULTS.windowSeconds,
|
|
7552
|
+
maxFailures: o?.maxFailures ?? OTP_DEFAULTS.maxFailures,
|
|
7553
|
+
lockoutSeconds: o?.lockoutSeconds ?? OTP_DEFAULTS.lockoutSeconds,
|
|
7554
|
+
maxLockouts: o?.maxLockouts ?? OTP_DEFAULTS.maxLockouts,
|
|
7555
|
+
statePath: o?.statePath ?? OTP_DEFAULTS.statePath
|
|
7556
|
+
};
|
|
7557
|
+
}
|
|
7558
|
+
function isGated(rule) {
|
|
7559
|
+
if (rule.name === OTP_STATUS) return false;
|
|
7560
|
+
return rule.otp ?? DEFAULT_GATED.has(rule.name);
|
|
7561
|
+
}
|
|
7562
|
+
function readSeed(config2, secrets) {
|
|
7563
|
+
if (!config2.otp) return { problem: "OTP is not set up on this broker (no config.otp); re-run the installer to set it up" };
|
|
7564
|
+
const name = config2.otp.seed.replace(/^cred:/, "");
|
|
7565
|
+
if (!secrets.has(config2.otp.seed)) return { problem: `OTP is not set up on this broker: its seed credential "${name}" is not stored (re-run the installer to generate it)` };
|
|
7566
|
+
let key2;
|
|
7567
|
+
try {
|
|
7568
|
+
key2 = base32Decode(secrets.resolve(config2.otp.seed).trim());
|
|
7569
|
+
} catch {
|
|
7570
|
+
key2 = Buffer.alloc(0);
|
|
7571
|
+
}
|
|
7572
|
+
if (key2.length === 0) return { problem: `OTP is not set up on this broker: the stored seed "${name}" is not valid base32 (re-run the installer to replace it)` };
|
|
7573
|
+
return { key: key2 };
|
|
7574
|
+
}
|
|
7575
|
+
function otpSetupProblem(config2, secrets) {
|
|
7576
|
+
const seed = readSeed(config2, secrets);
|
|
7577
|
+
return "problem" in seed ? seed.problem : void 0;
|
|
7578
|
+
}
|
|
7579
|
+
|
|
7580
|
+
// src/agents.ts
|
|
7581
|
+
import { isAbsolute as isAbsolute2 } from "node:path";
|
|
7582
|
+
var AGENT_USER = /^[a-z_][a-z0-9_-]{0,15}$/;
|
|
7583
|
+
var RESERVED_AGENT_USERS = ["__proto__", "root", "gh-broker"];
|
|
7584
|
+
function isAgentUser(name) {
|
|
7585
|
+
return AGENT_USER.test(name) && !RESERVED_AGENT_USERS.includes(name);
|
|
7586
|
+
}
|
|
7587
|
+
var AGENT_GROUP_PREFIX = "gh-broker-agent-";
|
|
7588
|
+
var SERVICE_USER = "gh-broker";
|
|
7589
|
+
function agentsDir(listen) {
|
|
7590
|
+
if (!listen.startsWith("agents:")) return void 0;
|
|
7591
|
+
const dir = listen.slice("agents:".length);
|
|
7592
|
+
return isAbsolute2(dir) ? dir : void 0;
|
|
7593
|
+
}
|
|
7594
|
+
function parseAgentGroups(groupFile) {
|
|
7595
|
+
const agents = [];
|
|
7596
|
+
const skipped = [];
|
|
7597
|
+
for (const line of groupFile.split("\n")) {
|
|
7598
|
+
const [group, , gidText, memberText] = line.split(":");
|
|
7599
|
+
if (!group?.startsWith(AGENT_GROUP_PREFIX)) continue;
|
|
7600
|
+
const user = group.slice(AGENT_GROUP_PREFIX.length);
|
|
7601
|
+
const gid = Number(gidText);
|
|
7602
|
+
if (!isAgentUser(user)) {
|
|
7603
|
+
skipped.push(`${group}: "${user}" is not a valid agent user name`);
|
|
7604
|
+
continue;
|
|
7605
|
+
}
|
|
7606
|
+
if (!gidText || !Number.isSafeInteger(gid)) {
|
|
7607
|
+
skipped.push(`${group}: bad gid`);
|
|
7608
|
+
continue;
|
|
7609
|
+
}
|
|
7610
|
+
const members2 = new Set((memberText ?? "").split(",").map((m) => m.trim()).filter(Boolean));
|
|
7611
|
+
if (members2.size !== 2 || !members2.has(SERVICE_USER) || !members2.has(user)) {
|
|
7612
|
+
skipped.push(`${group}: members must be exactly ${SERVICE_USER} and ${user}`);
|
|
7613
|
+
continue;
|
|
7614
|
+
}
|
|
7615
|
+
agents.push({ user, group, gid });
|
|
7616
|
+
}
|
|
7617
|
+
return { agents, skipped };
|
|
7618
|
+
}
|
|
7619
|
+
|
|
7620
|
+
// src/denylist.ts
|
|
7621
|
+
var DENIED_TOOLS = /* @__PURE__ */ new Map([
|
|
7622
|
+
["create_or_update_file", "file writes go through git"],
|
|
7623
|
+
["push_files", "file writes go through git"],
|
|
7624
|
+
["delete_file", "file writes go through git"],
|
|
7625
|
+
["delete_repository", "deleting a repo stays a manual step in GitHub"],
|
|
7626
|
+
["create_repository_ruleset", "rulesets are managed by org bootstrap"],
|
|
7627
|
+
["custom_properties_write", "repo configuration is managed by org bootstrap"]
|
|
7628
|
+
]);
|
|
7629
|
+
var WRITE_VERBS = "create|update|delete|write|set|add|remove|edit|manage|change";
|
|
7630
|
+
var writeTo = (subjects, verbs = WRITE_VERBS) => new RegExp(`(^|_)(${verbs})_(.*_)?(${subjects})$|(^|_)(${subjects})_(${verbs})$`);
|
|
7631
|
+
var DENIED_PATTERNS = [
|
|
7632
|
+
[writeTo("files?|contents?|commits?|trees?|blobs?|refs?|git_refs?|changes", "create|update|delete|write|push"), "file writes go through git"],
|
|
7633
|
+
[writeTo("repos?|repositor(y|ies)", "delete"), "deleting a repo stays a manual step in GitHub"],
|
|
7634
|
+
// Not `create`: create_repository is a gated tool, not repo configuration.
|
|
7635
|
+
[writeTo("repos?|repositor(y|ies)|repo_settings|repository_settings|settings|visibility|default_branch|topics", "update|edit|write|set|change|manage|replace|transfer|archive|unarchive|rename"), "repo configuration is managed by org bootstrap"],
|
|
7636
|
+
[writeTo("custom_property|custom_properties|custom_property_values?"), "repo configuration is managed by org bootstrap"],
|
|
7637
|
+
[writeTo("security_and_analysis|vulnerability_alerts|automated_security_fixes", `${WRITE_VERBS}|enable|disable`), "repo configuration is managed by org bootstrap"],
|
|
7638
|
+
[writeTo("workflows?", "enable|disable|write|update|delete"), "turning workflows on or off changes how CI behaves; org bootstrap manages it"],
|
|
7639
|
+
[writeTo("rulesets?"), "rulesets are managed by org bootstrap"],
|
|
7640
|
+
[writeTo("branch_protections?|branch_protection_rules?"), "branch protection is managed by org bootstrap"],
|
|
7641
|
+
[writeTo("variables?|secrets?"), "Actions variables and secrets are managed by org bootstrap"],
|
|
7642
|
+
[writeTo("workflow_permissions|actions_permissions"), "workflow permissions are managed by org bootstrap"],
|
|
7643
|
+
[writeTo("webhooks?|hooks?|deploy_keys?|collaborators?|environments?"), "repo access and configuration are managed by org bootstrap"],
|
|
7644
|
+
[/^(bulk|batch)_(close|lock|unlock|delete|update|write|edit|label|set|add|remove)|^close_((all|bulk)_)?(issues|pull_requests|prs)$|_(issues|pull_requests)_close$/, "bulk writes aren't offered; loop over the single-item tool"],
|
|
7645
|
+
[writeTo("issues?|conversations?|pull_requests?|discussions?", "lock|unlock"), "locking conversations isn't offered"],
|
|
7646
|
+
[/^lock_write$/, "locking conversations isn't offered"]
|
|
7647
|
+
];
|
|
7648
|
+
function deniedReason(name) {
|
|
7649
|
+
const exact = DENIED_TOOLS.get(name);
|
|
7650
|
+
if (exact) return exact;
|
|
7651
|
+
for (const [pattern, why] of DENIED_PATTERNS) if (pattern.test(name)) return why;
|
|
7652
|
+
return void 0;
|
|
7653
|
+
}
|
|
7654
|
+
|
|
7476
7655
|
// src/config.ts
|
|
7477
7656
|
var MODES = ["read", "write"];
|
|
7478
7657
|
function loadConfig(path) {
|
|
@@ -7484,8 +7663,11 @@ function loadConfig(path) {
|
|
|
7484
7663
|
}
|
|
7485
7664
|
if (typeof raw !== "object" || raw === null) throw new Error("config: root must be an object");
|
|
7486
7665
|
const c = raw;
|
|
7487
|
-
if (typeof c.listen
|
|
7488
|
-
throw new Error('config.listen:
|
|
7666
|
+
if (typeof c.listen === "string" && c.listen.startsWith("unix:")) {
|
|
7667
|
+
throw new Error('config.listen: the shared "unix:" socket was replaced by one socket per agent; re-run the installer (sudo gh-broker install) to set listen to "agents:/run/gh-broker/agents"');
|
|
7668
|
+
}
|
|
7669
|
+
if (typeof c.listen !== "string" || !agentsDir(c.listen)) {
|
|
7670
|
+
throw new Error('config.listen: must be "agents:<absolute directory>", e.g. "agents:/run/gh-broker/agents"');
|
|
7489
7671
|
}
|
|
7490
7672
|
if (typeof c.upstream !== "string" || !/^https?:\/\//.test(c.upstream)) {
|
|
7491
7673
|
throw new Error("config.upstream: must be an http(s) URL");
|
|
@@ -7499,11 +7681,20 @@ function loadConfig(path) {
|
|
|
7499
7681
|
if (typeof o.name !== "string" || !o.name) throw new Error(`config.tools[${i}].name: required`);
|
|
7500
7682
|
if (!MODES.includes(o.mode)) throw new Error(`config.tools[${i}].mode: must be read|write`);
|
|
7501
7683
|
if (seen.has(o.name)) throw new Error(`config.tools: duplicate tool "${o.name}"`);
|
|
7684
|
+
const denied = deniedReason(o.name);
|
|
7685
|
+
if (denied) throw new Error(`config.tools[${i}] ("${o.name}"): never allowed in gh-broker (${denied}); remove it from config.tools`);
|
|
7502
7686
|
seen.add(o.name);
|
|
7503
7687
|
const custom2 = o.custom === true;
|
|
7504
7688
|
const augment = o.augment === true;
|
|
7505
7689
|
if (custom2 && augment) throw new Error(`config.tools[${i}] ("${o.name}"): custom and augment are mutually exclusive`);
|
|
7506
|
-
|
|
7690
|
+
if (o.otp !== void 0 && typeof o.otp !== "boolean") throw new Error(`config.tools[${i}].otp: must be true or false`);
|
|
7691
|
+
if (o.enabled !== void 0 && typeof o.enabled !== "boolean") throw new Error(`config.tools[${i}].enabled: must be true or false`);
|
|
7692
|
+
if (o.name === OTP_STATUS) {
|
|
7693
|
+
if (o.otp === true) throw new Error(`config.tools[${i}] ("${OTP_STATUS}"): can't be gated (otp: true)`);
|
|
7694
|
+
if (o.enabled === false) throw new Error(`config.tools[${i}] ("${OTP_STATUS}"): can't be disabled (enabled: false)`);
|
|
7695
|
+
if (!custom2) throw new Error(`config.tools[${i}] ("${OTP_STATUS}"): must be custom: true`);
|
|
7696
|
+
}
|
|
7697
|
+
return { name: o.name, mode: o.mode, custom: custom2, augment, ...typeof o.otp === "boolean" ? { otp: o.otp } : {}, ...typeof o.enabled === "boolean" ? { enabled: o.enabled } : {} };
|
|
7507
7698
|
});
|
|
7508
7699
|
if (typeof c.owners !== "object" || c.owners === null) throw new Error("config.owners: must be an object");
|
|
7509
7700
|
const owners = {};
|
|
@@ -7580,6 +7771,7 @@ function loadConfig(path) {
|
|
|
7580
7771
|
...Array.isArray(n.tokenOrgs) ? { tokenOrgs: n.tokenOrgs } : {}
|
|
7581
7772
|
};
|
|
7582
7773
|
}
|
|
7774
|
+
const otp = c.otp !== void 0 ? parseOtpConfig(c.otp) : void 0;
|
|
7583
7775
|
return {
|
|
7584
7776
|
listen: c.listen,
|
|
7585
7777
|
upstream: c.upstream,
|
|
@@ -7589,9 +7781,27 @@ function loadConfig(path) {
|
|
|
7589
7781
|
...secrets ? { secrets } : {},
|
|
7590
7782
|
...defaultOwner ? { defaultOwner } : {},
|
|
7591
7783
|
...npm ? { npm } : {},
|
|
7592
|
-
...c.ghcr !== void 0 ? { ghcr: parseGhcrConfig(c.ghcr) } : {}
|
|
7784
|
+
...c.ghcr !== void 0 ? { ghcr: parseGhcrConfig(c.ghcr) } : {},
|
|
7785
|
+
...otp ? { otp } : {}
|
|
7593
7786
|
};
|
|
7594
7787
|
}
|
|
7788
|
+
function parseOtpConfig(raw) {
|
|
7789
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) throw new Error("config.otp: must be an object");
|
|
7790
|
+
const o = raw;
|
|
7791
|
+
if (typeof o.seed !== "string" || !o.seed.startsWith("cred:")) throw new Error('config.otp.seed: must be a "cred:<name>" ref');
|
|
7792
|
+
const out = { seed: o.seed };
|
|
7793
|
+
for (const key2 of ["windowSeconds", "maxFailures", "lockoutSeconds", "maxLockouts"]) {
|
|
7794
|
+
const v = o[key2];
|
|
7795
|
+
if (v === void 0) continue;
|
|
7796
|
+
if (typeof v !== "number" || !Number.isSafeInteger(v) || v <= 0) throw new Error(`config.otp.${key2}: must be a positive integer`);
|
|
7797
|
+
out[key2] = v;
|
|
7798
|
+
}
|
|
7799
|
+
if (o.statePath !== void 0) {
|
|
7800
|
+
if (typeof o.statePath !== "string" || !isAbsolute3(o.statePath)) throw new Error("config.otp.statePath: must be an absolute path");
|
|
7801
|
+
out.statePath = o.statePath;
|
|
7802
|
+
}
|
|
7803
|
+
return out;
|
|
7804
|
+
}
|
|
7595
7805
|
function indexTools(tools) {
|
|
7596
7806
|
return new Map(tools.map((t) => [t.name, t]));
|
|
7597
7807
|
}
|
|
@@ -7655,6 +7865,7 @@ function validateRefs(config2, read) {
|
|
|
7655
7865
|
assertSafeName(config2.npm.token.slice(CRED_PREFIX.length));
|
|
7656
7866
|
}
|
|
7657
7867
|
for (const ref of ghcrCredRefs(config2.ghcr)) credName(ref);
|
|
7868
|
+
if (config2.otp) credName(config2.otp.seed);
|
|
7658
7869
|
}
|
|
7659
7870
|
function systemdCredsStore(config2) {
|
|
7660
7871
|
const dir = process.env.CREDENTIALS_DIRECTORY;
|
|
@@ -9590,9 +9801,9 @@ var asciiTabOrNewline = /[\t\n\r]/g;
|
|
|
9590
9801
|
function stripTabAndNewline(value) {
|
|
9591
9802
|
return value.replace(asciiTabOrNewline, "");
|
|
9592
9803
|
}
|
|
9593
|
-
function urlHostnameOk(url2,
|
|
9594
|
-
|
|
9595
|
-
return
|
|
9804
|
+
function urlHostnameOk(url2, hostname2) {
|
|
9805
|
+
hostname2.lastIndex = 0;
|
|
9806
|
+
return hostname2.test(url2.hostname);
|
|
9596
9807
|
}
|
|
9597
9808
|
function urlProtocolOk(url2, protocol) {
|
|
9598
9809
|
protocol.lastIndex = 0;
|
|
@@ -10190,7 +10401,7 @@ var $ZodObject = /* @__PURE__ */ $constructor("$ZodObject", (inst, def) => {
|
|
|
10190
10401
|
}
|
|
10191
10402
|
return propValues;
|
|
10192
10403
|
});
|
|
10193
|
-
const
|
|
10404
|
+
const isObject4 = isObject2;
|
|
10194
10405
|
const catchall = def.catchall;
|
|
10195
10406
|
let value;
|
|
10196
10407
|
const memo2 = globalConfig.memoizer;
|
|
@@ -10198,7 +10409,7 @@ var $ZodObject = /* @__PURE__ */ $constructor("$ZodObject", (inst, def) => {
|
|
|
10198
10409
|
inst._zod.parse = (payload, ctx) => {
|
|
10199
10410
|
value ?? (value = _normalized.value);
|
|
10200
10411
|
const input = payload.value;
|
|
10201
|
-
if (!
|
|
10412
|
+
if (!isObject4(input)) {
|
|
10202
10413
|
payload.issues.push({
|
|
10203
10414
|
expected: "object",
|
|
10204
10415
|
code: "invalid_type",
|
|
@@ -10334,7 +10545,7 @@ var $ZodObjectJIT = /* @__PURE__ */ $constructor("$ZodObjectJIT", (inst, def) =>
|
|
|
10334
10545
|
return doc.compile();
|
|
10335
10546
|
};
|
|
10336
10547
|
let fastpass;
|
|
10337
|
-
const
|
|
10548
|
+
const isObject4 = isObject2;
|
|
10338
10549
|
const jit = !globalConfig.jitless;
|
|
10339
10550
|
const allowsEval2 = allowsEval;
|
|
10340
10551
|
const fastEnabled = jit && allowsEval2.value;
|
|
@@ -10343,7 +10554,7 @@ var $ZodObjectJIT = /* @__PURE__ */ $constructor("$ZodObjectJIT", (inst, def) =>
|
|
|
10343
10554
|
inst._zod.parse = (payload, ctx) => {
|
|
10344
10555
|
value ?? (value = _normalized.value);
|
|
10345
10556
|
const input = payload.value;
|
|
10346
|
-
if (!
|
|
10557
|
+
if (!isObject4(input)) {
|
|
10347
10558
|
payload.issues.push({
|
|
10348
10559
|
expected: "object",
|
|
10349
10560
|
code: "invalid_type",
|
|
@@ -17798,8 +18009,8 @@ async function random(size) {
|
|
|
17798
18009
|
const evenDistCutoff = Math.pow(2, 8) - Math.pow(2, 8) % mask.length;
|
|
17799
18010
|
let result = "";
|
|
17800
18011
|
while (result.length < size) {
|
|
17801
|
-
const
|
|
17802
|
-
for (const randomByte of
|
|
18012
|
+
const randomBytes3 = await getRandomValues(size - result.length);
|
|
18013
|
+
for (const randomByte of randomBytes3) {
|
|
17803
18014
|
if (randomByte < evenDistCutoff) {
|
|
17804
18015
|
result += mask[randomByte % mask.length];
|
|
17805
18016
|
}
|
|
@@ -19985,8 +20196,8 @@ var stringifyIteratively = (rootValue, replacer, spaceParam) => {
|
|
|
19985
20196
|
const isFunctionReplacer = typeof replacer === "function";
|
|
19986
20197
|
const propertyList = Array.isArray(replacer) ? new Set(replacer.map(String)) : null;
|
|
19987
20198
|
const prepareVal = (parent, key2, val) => {
|
|
19988
|
-
const
|
|
19989
|
-
const hasToJSON =
|
|
20199
|
+
const isObject4 = val !== null && typeof val === "object";
|
|
20200
|
+
const hasToJSON = isObject4 && typeof val.toJSON === "function";
|
|
19990
20201
|
if (hasToJSON) {
|
|
19991
20202
|
val = val.toJSON(key2);
|
|
19992
20203
|
}
|
|
@@ -20820,7 +21031,14 @@ function audit(entry) {
|
|
|
20820
21031
|
|
|
20821
21032
|
// src/availability.ts
|
|
20822
21033
|
var GHCR_TOOLS = /* @__PURE__ */ new Set(["ghcr_push", "ghcr_push_status", "ghcr_tags"]);
|
|
21034
|
+
var DEFAULT_DISABLED = /* @__PURE__ */ new Set(["create_pull_request_with_copilot", "assign_copilot_to_issue", "assign_copilot_to_issue_with_intent"]);
|
|
21035
|
+
function isEnabled(rule) {
|
|
21036
|
+
return rule.enabled ?? !DEFAULT_DISABLED.has(rule.name);
|
|
21037
|
+
}
|
|
20823
21038
|
function unavailableReason(rule, config2, secrets) {
|
|
21039
|
+
if (!isEnabled(rule)) {
|
|
21040
|
+
return DEFAULT_DISABLED.has(rule.name) && rule.enabled === void 0 ? `it is disabled by default; set "enabled": true on its entry in config.tools to turn it on` : `it is disabled in config.tools ("enabled": false)`;
|
|
21041
|
+
}
|
|
20824
21042
|
if (rule.name === "npm_scaffold") {
|
|
20825
21043
|
if (!config2.npm) return "the npm integration is not configured (enable it with the installer's --npm)";
|
|
20826
21044
|
if (!secrets.has(config2.npm.token)) return `its credential "${config2.npm.token.replace(/^cred:/, "")}" is not stored (re-run the installer with --npm)`;
|
|
@@ -20832,6 +21050,7 @@ function unavailableReason(rule, config2, secrets) {
|
|
|
20832
21050
|
return `no GHCR credential is stored (${refs.map((r) => `"${r.replace(/^cred:/, "")}"`).join(", ")}; re-run the installer to add one)`;
|
|
20833
21051
|
}
|
|
20834
21052
|
}
|
|
21053
|
+
if (isGated(rule)) return otpSetupProblem(config2, secrets);
|
|
20835
21054
|
return void 0;
|
|
20836
21055
|
}
|
|
20837
21056
|
|
|
@@ -21249,14 +21468,17 @@ var SEARCH_PAGE_SIZE = 100;
|
|
|
21249
21468
|
var SEARCH_MAX_PAGES = 10;
|
|
21250
21469
|
var LOCKFILES = ["package-lock.json", "pnpm-lock.yaml"];
|
|
21251
21470
|
var NAME_PATTERN = /^(@[a-z0-9~-][a-z0-9._~-]*\/)?[a-z0-9~-][a-z0-9._~-]*$/;
|
|
21252
|
-
var
|
|
21471
|
+
var DEV_LIST_PAGE_SIZE = 25;
|
|
21472
|
+
var DEV_LIST_MIN_PAGE_SIZE = 5;
|
|
21473
|
+
var DEV_LIST_MAX_PAGES = 80;
|
|
21474
|
+
var TIMEOUT_STATUSES = /* @__PURE__ */ new Set([502, 503, 504]);
|
|
21253
21475
|
var DEV_BRANCH = "dev";
|
|
21254
21476
|
var SEARCH_NOTE = "Candidates come from GitHub code search, which lags recent commits and only indexes default branches; each candidate was verified against its default-branch package.json. A repo that changed very recently can be missing.";
|
|
21255
21477
|
var DEV_NOTE = " Every `dev` branch that differs from its default branch was also read in full, so dev-trunk repos are included; a `dev` whose root package.json version is below the default branch's is treated as abandoned and skipped (see `abandonedDev`).";
|
|
21256
21478
|
var NO_DEV_NOTE = " Dev branches were not read, so a dev-trunk repo whose real manifests live on `dev` is missing.";
|
|
21257
|
-
var DEV_BRANCHES_QUERY = `query($owner: String!, $after: String) {
|
|
21479
|
+
var DEV_BRANCHES_QUERY = `query($owner: String!, $after: String, $first: Int!) {
|
|
21258
21480
|
repositoryOwner(login: $owner) {
|
|
21259
|
-
repositories(first:
|
|
21481
|
+
repositories(first: $first, after: $after, orderBy: { field: NAME, direction: ASC }) {
|
|
21260
21482
|
pageInfo { hasNextPage endCursor }
|
|
21261
21483
|
nodes {
|
|
21262
21484
|
name
|
|
@@ -21401,8 +21623,17 @@ async function listDevBranches(octokit, budget, owner2) {
|
|
|
21401
21623
|
const repos = [];
|
|
21402
21624
|
const abandoned = [];
|
|
21403
21625
|
let after = null;
|
|
21626
|
+
let first = DEV_LIST_PAGE_SIZE;
|
|
21404
21627
|
for (let page = 1; page <= DEV_LIST_MAX_PAGES; page++) {
|
|
21405
|
-
|
|
21628
|
+
let res;
|
|
21629
|
+
try {
|
|
21630
|
+
res = await reqOne(octokit, budget, "POST /graphql", { query: DEV_BRANCHES_QUERY, variables: { owner: owner2, after, first } });
|
|
21631
|
+
} catch (err) {
|
|
21632
|
+
if (!TIMEOUT_STATUSES.has(err.status)) throw err;
|
|
21633
|
+
if (first === DEV_LIST_MIN_PAGE_SIZE) return { repos, abandoned, complete: false, timedOut: true };
|
|
21634
|
+
first = Math.max(DEV_LIST_MIN_PAGE_SIZE, Math.ceil(first / 2));
|
|
21635
|
+
continue;
|
|
21636
|
+
}
|
|
21406
21637
|
const conn = res.data?.repositoryOwner?.repositories;
|
|
21407
21638
|
if (!conn) throw new Error(`report_dependents: listing dev branches failed: ${res.errors?.map((e) => e.message).join("; ") ?? `no owner "${owner2}"`}`);
|
|
21408
21639
|
for (const r of conn.nodes) {
|
|
@@ -21506,8 +21737,9 @@ async function reportDependents(octokit, input, deps = {}) {
|
|
|
21506
21737
|
dependents.push(entry);
|
|
21507
21738
|
}
|
|
21508
21739
|
if (devBranches) {
|
|
21509
|
-
const { repos, abandoned, complete } = await listDevBranches(octokit, budget, owner2);
|
|
21740
|
+
const { repos, abandoned, complete, timedOut } = await listDevBranches(octokit, budget, owner2);
|
|
21510
21741
|
abandonedDev.push(...abandoned);
|
|
21742
|
+
if (timedOut) truncatedReason ??= "github-timeout";
|
|
21511
21743
|
if (!complete) truncatedReason ??= "max-repos";
|
|
21512
21744
|
const room = Math.max(0, maxRepos - Math.min(repoNames.length, maxRepos));
|
|
21513
21745
|
if (repos.length > room) truncatedReason ??= "max-repos";
|
|
@@ -23295,7 +23527,7 @@ function tarballDigests(tarball) {
|
|
|
23295
23527
|
var PLACEHOLDER_VERSION = "0.0.0-stage";
|
|
23296
23528
|
var SCAFFOLD_MARKER = "gh-broker";
|
|
23297
23529
|
var DEFAULT_WORKFLOW = "publish.yml";
|
|
23298
|
-
var INPUT_KEYS2 = /* @__PURE__ */ new Set(["owner", "repo", "package", "
|
|
23530
|
+
var INPUT_KEYS2 = /* @__PURE__ */ new Set(["owner", "repo", "package", "npm_otp", "workflow"]);
|
|
23299
23531
|
var PACKAGE_NAME = /^@[a-z0-9][a-z0-9._~-]*\/[a-z0-9][a-z0-9._~-]*$/;
|
|
23300
23532
|
var NPM_SCAFFOLD_SCHEMA = {
|
|
23301
23533
|
type: "object",
|
|
@@ -23303,10 +23535,10 @@ var NPM_SCAFFOLD_SCHEMA = {
|
|
|
23303
23535
|
owner: { type: "string", description: "Repository owner (a configured owner)." },
|
|
23304
23536
|
repo: { type: "string", description: "Repository whose package.json names the package." },
|
|
23305
23537
|
package: { type: "string", description: 'Scoped package name, e.g. "@cldmv/new-thing". Must be under a scope in config.npm.scopes.' },
|
|
23306
|
-
|
|
23538
|
+
npm_otp: { type: "string", description: "npm one-time password the user read from their authenticator. Ask the user for it right before calling; codes expire in about 30 seconds." },
|
|
23307
23539
|
workflow: { type: "string", description: `The repo's publish workflow file in .github/workflows. Default "publish.yml".` }
|
|
23308
23540
|
},
|
|
23309
|
-
required: ["owner", "repo", "package", "
|
|
23541
|
+
required: ["owner", "repo", "package", "npm_otp"],
|
|
23310
23542
|
additionalProperties: false
|
|
23311
23543
|
};
|
|
23312
23544
|
var fail3 = (msg) => {
|
|
@@ -23320,7 +23552,7 @@ function validate3(input) {
|
|
|
23320
23552
|
if (typeof a.owner !== "string" || !/^[A-Za-z0-9-]+$/.test(a.owner)) fail3('"owner" must be a GitHub owner name');
|
|
23321
23553
|
if (typeof a.repo !== "string" || !/^[A-Za-z0-9._-]+$/.test(a.repo)) fail3('"repo" must be a repository name');
|
|
23322
23554
|
if (typeof a.package !== "string" || a.package.length > 214 || !PACKAGE_NAME.test(a.package)) fail3('"package" must be a lowercase scoped npm package name, e.g. "@cldmv/new-thing"');
|
|
23323
|
-
if (typeof a.
|
|
23555
|
+
if (typeof a.npm_otp !== "string" || !/^[0-9]{6,8}$/.test(a.npm_otp)) fail3('"npm_otp" must be the 6-8 digit npm code from the authenticator');
|
|
23324
23556
|
if (a.workflow !== void 0 && (typeof a.workflow !== "string" || !/^[A-Za-z0-9._-]+\.ya?ml$/.test(a.workflow))) {
|
|
23325
23557
|
fail3('"workflow" must be a workflow file name in .github/workflows, e.g. "publish.yml"');
|
|
23326
23558
|
}
|
|
@@ -23328,7 +23560,7 @@ function validate3(input) {
|
|
|
23328
23560
|
owner: a.owner,
|
|
23329
23561
|
repo: a.repo,
|
|
23330
23562
|
package: a.package,
|
|
23331
|
-
|
|
23563
|
+
npm_otp: a.npm_otp,
|
|
23332
23564
|
workflow: a.workflow ?? DEFAULT_WORKFLOW
|
|
23333
23565
|
};
|
|
23334
23566
|
}
|
|
@@ -23439,7 +23671,7 @@ async function npmScaffold(input, deps) {
|
|
|
23439
23671
|
const existing = await deps.registry.packument(args.package);
|
|
23440
23672
|
if (!existing) {
|
|
23441
23673
|
try {
|
|
23442
|
-
await deps.registry.publish(placeholderDocument(args.package, facts, access, deps.registry.registry), args.package, args.
|
|
23674
|
+
await deps.registry.publish(placeholderDocument(args.package, facts, access, deps.registry.registry), args.package, args.npm_otp);
|
|
23443
23675
|
} catch (err) {
|
|
23444
23676
|
if (err instanceof NpmOtpRequired) {
|
|
23445
23677
|
return { ...base, status: "needs_otp", done: [], remaining: ["publish", "trust"], message: "npm refused the one-time password before publishing; nothing was written. Call again with a fresh code." };
|
|
@@ -23464,7 +23696,7 @@ async function npmScaffold(input, deps) {
|
|
|
23464
23696
|
}
|
|
23465
23697
|
let out;
|
|
23466
23698
|
try {
|
|
23467
|
-
out = await deps.registry.addGithubTrust(args.package, { repository: facts.repository, file: args.workflow }, args.
|
|
23699
|
+
out = await deps.registry.addGithubTrust(args.package, { repository: facts.repository, file: args.workflow }, args.npm_otp);
|
|
23468
23700
|
} catch (err) {
|
|
23469
23701
|
if (err instanceof NpmOtpRequired) {
|
|
23470
23702
|
return {
|
|
@@ -23511,10 +23743,187 @@ async function npmScaffold(input, deps) {
|
|
|
23511
23743
|
};
|
|
23512
23744
|
}
|
|
23513
23745
|
|
|
23746
|
+
// src/verbs/minimize_comment.ts
|
|
23747
|
+
var MINIMIZE_CLASSIFIERS = ["RESOLVED", "OUTDATED", "DUPLICATE", "OFF_TOPIC", "SPAM", "ABUSE"];
|
|
23748
|
+
var MINIMIZE = `
|
|
23749
|
+
mutation($id: ID!, $classifier: ReportedContentClassifiers!) {
|
|
23750
|
+
minimizeComment(input: { subjectId: $id, classifier: $classifier }) {
|
|
23751
|
+
minimizedComment { isMinimized minimizedReason }
|
|
23752
|
+
}
|
|
23753
|
+
}`;
|
|
23754
|
+
var UNMINIMIZE = `
|
|
23755
|
+
mutation($id: ID!) {
|
|
23756
|
+
unminimizeComment(input: { subjectId: $id }) {
|
|
23757
|
+
unminimizedComment { isMinimized minimizedReason }
|
|
23758
|
+
}
|
|
23759
|
+
}`;
|
|
23760
|
+
async function nodeIdOf(octokit, input) {
|
|
23761
|
+
if (input.commentId !== void 0) {
|
|
23762
|
+
if (typeof input.commentId !== "string" || !input.commentId.trim()) throw new Error("minimize_comment: commentId must be a comment node id");
|
|
23763
|
+
if (input.commentNumber !== void 0) throw new Error("minimize_comment: pass commentId or commentNumber, not both");
|
|
23764
|
+
return input.commentId;
|
|
23765
|
+
}
|
|
23766
|
+
if (!Number.isSafeInteger(input.commentNumber) || input.commentNumber <= 0) {
|
|
23767
|
+
throw new Error("minimize_comment: pass commentId (node id), or commentNumber (REST id) with type and repo");
|
|
23768
|
+
}
|
|
23769
|
+
if (input.type !== "issue" && input.type !== "review") throw new Error('minimize_comment: commentNumber needs type "issue" (issue or PR conversation comment) or "review" (PR review comment)');
|
|
23770
|
+
if (typeof input.repo !== "string" || !input.repo) throw new Error("minimize_comment: commentNumber needs repo");
|
|
23771
|
+
const route = input.type === "issue" ? "GET /repos/{owner}/{repo}/issues/comments/{comment_id}" : "GET /repos/{owner}/{repo}/pulls/comments/{comment_id}";
|
|
23772
|
+
const res = await octokit.request(route, { owner: input.owner, repo: input.repo, comment_id: input.commentNumber });
|
|
23773
|
+
const nodeId = res.data.node_id;
|
|
23774
|
+
if (typeof nodeId !== "string" || !nodeId) throw new Error(`minimize_comment: GitHub returned no node id for ${input.type} comment ${input.commentNumber}`);
|
|
23775
|
+
return nodeId;
|
|
23776
|
+
}
|
|
23777
|
+
async function minimizeComment(octokit, input) {
|
|
23778
|
+
const classifier = input.classifier ?? "RESOLVED";
|
|
23779
|
+
if (input.unhide === true && input.classifier !== void 0) throw new Error("minimize_comment: unhide takes no classifier");
|
|
23780
|
+
if (!MINIMIZE_CLASSIFIERS.includes(classifier)) throw new Error(`minimize_comment: classifier must be one of ${MINIMIZE_CLASSIFIERS.join(", ")}`);
|
|
23781
|
+
const id = await nodeIdOf(octokit, input);
|
|
23782
|
+
if (input.unhide === true) {
|
|
23783
|
+
const data2 = await octokit.graphql(UNMINIMIZE, { id });
|
|
23784
|
+
const c2 = data2.unminimizeComment?.unminimizedComment;
|
|
23785
|
+
if (!c2) throw new Error(`minimize_comment: GitHub returned no comment for "${id}"`);
|
|
23786
|
+
return { commentId: id, isMinimized: c2.isMinimized, minimizedReason: c2.minimizedReason ?? null };
|
|
23787
|
+
}
|
|
23788
|
+
const data = await octokit.graphql(MINIMIZE, { id, classifier });
|
|
23789
|
+
const c = data.minimizeComment?.minimizedComment;
|
|
23790
|
+
if (!c) throw new Error(`minimize_comment: GitHub returned no comment for "${id}"`);
|
|
23791
|
+
return { commentId: id, isMinimized: c.isMinimized, minimizedReason: c.minimizedReason ?? null };
|
|
23792
|
+
}
|
|
23793
|
+
var MINIMIZE_COMMENT_DESCRIPTION = `Hide a comment on an issue or PR (collapsed, like "Hide \u2192 Resolved" in GitHub), or show it again with unhide: true. Meant for folding away bot review comments once they're addressed. Name the comment by node id (commentId, e.g. from pull_request_read get_review_comments), or by REST id (commentNumber) with type "issue" (issue or PR conversation comment) or "review" (PR review comment) and repo. Runs on the write PAT.`;
|
|
23794
|
+
var MINIMIZE_COMMENT_SCHEMA = {
|
|
23795
|
+
type: "object",
|
|
23796
|
+
required: ["owner"],
|
|
23797
|
+
additionalProperties: false,
|
|
23798
|
+
properties: {
|
|
23799
|
+
owner: { type: "string", description: "repository owner (org/user login); selects the write credential" },
|
|
23800
|
+
repo: { type: "string", description: "repository name (needed with commentNumber)" },
|
|
23801
|
+
commentId: { type: "string", description: 'comment node id, e.g. "IC_\u2026" or "PRRC_\u2026"' },
|
|
23802
|
+
commentNumber: { type: "number", description: "REST comment id, used with type and repo when the node id isn't at hand" },
|
|
23803
|
+
type: { type: "string", enum: ["issue", "review"], description: "with commentNumber: issue (issue or PR conversation comment) or review (PR review comment)" },
|
|
23804
|
+
classifier: { type: "string", enum: [...MINIMIZE_CLASSIFIERS], description: "why it's hidden (default RESOLVED)" },
|
|
23805
|
+
unhide: { type: "boolean", description: "true shows a hidden comment again" }
|
|
23806
|
+
}
|
|
23807
|
+
};
|
|
23808
|
+
|
|
23809
|
+
// src/verbs/delete_branch.ts
|
|
23810
|
+
var KEPT_BRANCHES = /* @__PURE__ */ new Set(["master", "main", "next", "hotfixes", "dev", "badges", "gh-pages"]);
|
|
23811
|
+
async function deleteBranch(octokit, input) {
|
|
23812
|
+
const { owner: owner2, repo } = input;
|
|
23813
|
+
if (typeof repo !== "string" || !repo) throw new Error("delete_branch: repo is required");
|
|
23814
|
+
const branch = typeof input.branch === "string" ? input.branch.replace(/^refs\/heads\//, "") : "";
|
|
23815
|
+
if (!branch) throw new Error("delete_branch: branch is required");
|
|
23816
|
+
if (KEPT_BRANCHES.has(branch)) throw new Error(`delete_branch: refusing to delete "${branch}": the release flow depends on it`);
|
|
23817
|
+
const meta2 = await octokit.request("GET /repos/{owner}/{repo}", { owner: owner2, repo });
|
|
23818
|
+
const repoData = meta2.data;
|
|
23819
|
+
const defaultBranch = repoData.default_branch;
|
|
23820
|
+
if (branch === defaultBranch) throw new Error(`delete_branch: refusing to delete "${branch}": it is ${owner2}/${repo}'s default branch`);
|
|
23821
|
+
const lookups = [
|
|
23822
|
+
{ side: "head", where: { owner: owner2, repo }, filter: { head: `${owner2}:${branch}` } },
|
|
23823
|
+
{ side: "base", where: { owner: owner2, repo }, filter: { base: branch } }
|
|
23824
|
+
];
|
|
23825
|
+
const upstreams = repoData.fork ? [repoData.parent, repoData.source] : [];
|
|
23826
|
+
const seen = /* @__PURE__ */ new Set();
|
|
23827
|
+
for (const up of upstreams) {
|
|
23828
|
+
if (!up?.owner?.login || !up.name || seen.has(`${up.owner.login}/${up.name}`)) continue;
|
|
23829
|
+
seen.add(`${up.owner.login}/${up.name}`);
|
|
23830
|
+
lookups.push({ side: "head", where: { owner: up.owner.login, repo: up.name }, filter: { head: `${owner2}:${branch}` } });
|
|
23831
|
+
}
|
|
23832
|
+
for (const { side, where, filter } of lookups) {
|
|
23833
|
+
const open2 = await octokit.request("GET /repos/{owner}/{repo}/pulls", { ...where, state: "open", ...filter, per_page: 10 });
|
|
23834
|
+
const prs = open2.data;
|
|
23835
|
+
if (prs.length > 0) {
|
|
23836
|
+
const ref2 = where.owner === owner2 && where.repo === repo ? "" : `${where.owner}/${where.repo}`;
|
|
23837
|
+
throw new Error(`delete_branch: refusing to delete "${branch}": it is the ${side} of open PR ${prs.map((p) => `${ref2}#${p.number}`).join(", ")}, which deleting it would close`);
|
|
23838
|
+
}
|
|
23839
|
+
}
|
|
23840
|
+
const missing = new Error(`delete_branch: no branch "${branch}" in ${owner2}/${repo}`);
|
|
23841
|
+
let ref;
|
|
23842
|
+
try {
|
|
23843
|
+
ref = await octokit.request("GET /repos/{owner}/{repo}/git/ref/{ref}", { owner: owner2, repo, ref: `heads/${branch}` });
|
|
23844
|
+
} catch (err) {
|
|
23845
|
+
throw err.status === 404 ? missing : err;
|
|
23846
|
+
}
|
|
23847
|
+
const sha = ref.data.object?.sha;
|
|
23848
|
+
if (typeof sha !== "string") throw missing;
|
|
23849
|
+
await octokit.request("DELETE /repos/{owner}/{repo}/git/refs/{ref}", { owner: owner2, repo, ref: `heads/${branch}` });
|
|
23850
|
+
return { deleted: branch, sha, restore: `git push origin ${sha}:refs/heads/${branch}` };
|
|
23851
|
+
}
|
|
23852
|
+
var DELETE_BRANCH_DESCRIPTION = "Delete one branch on GitHub. Refuses the repo's default branch, the release-flow branches (master, main, next, hotfixes, dev, badges, gh-pages), and any branch that is the head or base of an open PR, including a PR from a fork into its parent or the root of its fork network (deleting it would close the PR). Rulesets still apply. Returns the deleted tip's sha and the git command that restores it. Runs on the write PAT (Contents: write).";
|
|
23853
|
+
var DELETE_BRANCH_SCHEMA = {
|
|
23854
|
+
type: "object",
|
|
23855
|
+
required: ["owner", "repo", "branch"],
|
|
23856
|
+
additionalProperties: false,
|
|
23857
|
+
properties: {
|
|
23858
|
+
owner: { type: "string", description: "repository owner (org/user login); selects the write credential" },
|
|
23859
|
+
repo: { type: "string", description: "repository name" },
|
|
23860
|
+
branch: { type: "string", description: 'branch name, e.g. "feat/otp-gate" (a leading refs/heads/ is accepted)' }
|
|
23861
|
+
}
|
|
23862
|
+
};
|
|
23863
|
+
|
|
23864
|
+
// src/verbs/dismiss_alert.ts
|
|
23865
|
+
var ALERT_KINDS = ["code_scanning", "dependabot", "secret_scanning"];
|
|
23866
|
+
var DISMISS_REASONS = {
|
|
23867
|
+
code_scanning: ["false positive", "won't fix", "used in tests"],
|
|
23868
|
+
dependabot: ["fix_started", "inaccurate", "no_bandwidth", "not_used", "tolerable_risk"],
|
|
23869
|
+
secret_scanning: ["false_positive", "wont_fix", "revoked", "used_in_tests"]
|
|
23870
|
+
};
|
|
23871
|
+
var ROUTES = {
|
|
23872
|
+
code_scanning: "PATCH /repos/{owner}/{repo}/code-scanning/alerts/{alert_number}",
|
|
23873
|
+
dependabot: "PATCH /repos/{owner}/{repo}/dependabot/alerts/{alert_number}",
|
|
23874
|
+
secret_scanning: "PATCH /repos/{owner}/{repo}/secret-scanning/alerts/{alert_number}"
|
|
23875
|
+
};
|
|
23876
|
+
function bodyFor(kind, reopen, reason, comment) {
|
|
23877
|
+
if (kind === "secret_scanning") {
|
|
23878
|
+
if (reopen) return { state: "open", ...comment ? { resolution_comment: comment } : {} };
|
|
23879
|
+
return { state: "resolved", resolution: reason, ...comment ? { resolution_comment: comment } : {} };
|
|
23880
|
+
}
|
|
23881
|
+
if (reopen) return { state: "open" };
|
|
23882
|
+
return { state: "dismissed", dismissed_reason: reason, ...comment ? { dismissed_comment: comment } : {} };
|
|
23883
|
+
}
|
|
23884
|
+
async function dismissAlert(octokit, input) {
|
|
23885
|
+
const { owner: owner2, repo, kind } = input;
|
|
23886
|
+
if (typeof repo !== "string" || !repo) throw new Error("dismiss_alert: repo is required");
|
|
23887
|
+
if (!ALERT_KINDS.includes(kind)) throw new Error(`dismiss_alert: kind must be one of ${ALERT_KINDS.join(", ")}`);
|
|
23888
|
+
if (!Number.isSafeInteger(input.alertNumber) || input.alertNumber <= 0) throw new Error("dismiss_alert: alertNumber must be a positive integer");
|
|
23889
|
+
if (input.comment !== void 0 && typeof input.comment !== "string") throw new Error("dismiss_alert: comment must be a string");
|
|
23890
|
+
const reopen = input.reopen === true;
|
|
23891
|
+
if (reopen && input.reason !== void 0) throw new Error("dismiss_alert: a reopen takes no reason");
|
|
23892
|
+
if (reopen && input.comment !== void 0 && kind !== "secret_scanning") throw new Error(`dismiss_alert: GitHub stores no comment when a ${kind} alert is reopened; leave comment out`);
|
|
23893
|
+
if (!reopen && !DISMISS_REASONS[kind].includes(input.reason)) {
|
|
23894
|
+
throw new Error(`dismiss_alert: reason is required for a ${kind} alert, one of: ${DISMISS_REASONS[kind].map((r) => `"${r}"`).join(", ")}`);
|
|
23895
|
+
}
|
|
23896
|
+
const res = await octokit.request(ROUTES[kind], { owner: owner2, repo, alert_number: input.alertNumber, ...bodyFor(kind, reopen, input.reason, input.comment) });
|
|
23897
|
+
const d = res.data;
|
|
23898
|
+
const reason = kind === "secret_scanning" ? d.resolution : d.dismissed_reason;
|
|
23899
|
+
return {
|
|
23900
|
+
kind,
|
|
23901
|
+
number: typeof d.number === "number" ? d.number : input.alertNumber,
|
|
23902
|
+
state: typeof d.state === "string" ? d.state : "unknown",
|
|
23903
|
+
reason: typeof reason === "string" ? reason : null,
|
|
23904
|
+
url: typeof d.html_url === "string" ? d.html_url : null
|
|
23905
|
+
};
|
|
23906
|
+
}
|
|
23907
|
+
var DISMISS_ALERT_DESCRIPTION = `Dismiss one code scanning, Dependabot or secret scanning alert, or reopen it with reopen: true. A dismissal needs a reason, which depends on kind: code_scanning ${DISMISS_REASONS.code_scanning.map((r) => `"${r}"`).join(" / ")}; dependabot ${DISMISS_REASONS.dependabot.join(" / ")}; secret_scanning ${DISMISS_REASONS.secret_scanning.join(" / ")}. Returns the alert's number, state, reason and URL only; a secret's value is never returned. Runs on the write PAT.`;
|
|
23908
|
+
var DISMISS_ALERT_SCHEMA = {
|
|
23909
|
+
type: "object",
|
|
23910
|
+
required: ["owner", "repo", "kind", "alertNumber"],
|
|
23911
|
+
additionalProperties: false,
|
|
23912
|
+
properties: {
|
|
23913
|
+
owner: { type: "string", description: "repository owner (org/user login); selects the write credential" },
|
|
23914
|
+
repo: { type: "string", description: "repository name" },
|
|
23915
|
+
kind: { type: "string", enum: [...ALERT_KINDS], description: "which alert type" },
|
|
23916
|
+
alertNumber: { type: "number", description: "the alert number" },
|
|
23917
|
+
reason: { type: "string", description: "why it's dismissed; the accepted values depend on kind (see the description)" },
|
|
23918
|
+
comment: { type: "string", description: "optional note stored with the dismissal" },
|
|
23919
|
+
reopen: { type: "boolean", description: "true reopens the alert instead of dismissing it (no reason)" }
|
|
23920
|
+
}
|
|
23921
|
+
};
|
|
23922
|
+
|
|
23514
23923
|
// src/verbs/ghcr/archive.ts
|
|
23515
23924
|
import { closeSync, constants, fstatSync, openSync, readlinkSync, realpathSync } from "node:fs";
|
|
23516
23925
|
import { createHash as createHash2 } from "node:crypto";
|
|
23517
|
-
import { dirname, isAbsolute as
|
|
23926
|
+
import { dirname, isAbsolute as isAbsolute4, join as join3, normalize as normalize2 } from "node:path";
|
|
23518
23927
|
|
|
23519
23928
|
// src/verbs/ghcr/tar.ts
|
|
23520
23929
|
import { readSync } from "node:fs";
|
|
@@ -23685,7 +24094,7 @@ var fail5 = (msg) => {
|
|
|
23685
24094
|
};
|
|
23686
24095
|
function openArchive(input, dropDir, maxBytes) {
|
|
23687
24096
|
if (typeof input !== "string" || !input || input.length > 4096 || input.includes("\0")) fail5('"archive" must be a file path inside the drop directory');
|
|
23688
|
-
const candidate = normalize2(
|
|
24097
|
+
const candidate = normalize2(isAbsolute4(input) ? input : join3(dropDir, input));
|
|
23689
24098
|
const outside = `"archive" must be an existing file inside the drop directory ${dropDir}`;
|
|
23690
24099
|
if (!candidate.startsWith(`${dropDir}/`)) fail5(outside);
|
|
23691
24100
|
let realDrop;
|
|
@@ -24400,6 +24809,349 @@ var GHCR_CATALOG = {
|
|
|
24400
24809
|
}
|
|
24401
24810
|
};
|
|
24402
24811
|
|
|
24812
|
+
// src/otp/gate.ts
|
|
24813
|
+
import { createHmac as createHmac2, randomBytes as randomBytes2 } from "node:crypto";
|
|
24814
|
+
import { hostname } from "node:os";
|
|
24815
|
+
|
|
24816
|
+
// src/otp/state.ts
|
|
24817
|
+
import { closeSync as closeSync2, fstatSync as fstatSync2, fsyncSync, openSync as openSync2, readFileSync as readFileSync3, renameSync, statSync, unlinkSync, writeSync } from "node:fs";
|
|
24818
|
+
import { randomBytes } from "node:crypto";
|
|
24819
|
+
import { dirname as dirname2 } from "node:path";
|
|
24820
|
+
function emptyState() {
|
|
24821
|
+
return { version: 1, lastAcceptedStep: 0, users: {} };
|
|
24822
|
+
}
|
|
24823
|
+
function emptyUser() {
|
|
24824
|
+
return { failures: 0, lockedUntil: null, lockouts: [], hardLock: null };
|
|
24825
|
+
}
|
|
24826
|
+
function userState(state, user) {
|
|
24827
|
+
const u = Object.hasOwn(state.users, user) ? state.users[user] : emptyUser();
|
|
24828
|
+
return { ...u, lockouts: [...u.lockouts] };
|
|
24829
|
+
}
|
|
24830
|
+
var isIso = (v) => typeof v === "string" && !Number.isNaN(Date.parse(v));
|
|
24831
|
+
var isCount = (v) => typeof v === "number" && Number.isSafeInteger(v) && v >= 0;
|
|
24832
|
+
var isObject3 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
24833
|
+
function parseUser(raw) {
|
|
24834
|
+
if (!isObject3(raw) || !isCount(raw.failures)) return void 0;
|
|
24835
|
+
if (raw.lockedUntil !== null && !isIso(raw.lockedUntil)) return void 0;
|
|
24836
|
+
if (!Array.isArray(raw.lockouts) || !raw.lockouts.every(isIso)) return void 0;
|
|
24837
|
+
let hardLock = null;
|
|
24838
|
+
if (raw.hardLock !== null) {
|
|
24839
|
+
const h = raw.hardLock;
|
|
24840
|
+
if (!isObject3(h) || !isIso(h.since) || !isCount(h.lockouts)) return void 0;
|
|
24841
|
+
hardLock = { since: h.since, lockouts: h.lockouts };
|
|
24842
|
+
}
|
|
24843
|
+
return { failures: raw.failures, lockedUntil: raw.lockedUntil, lockouts: [...raw.lockouts], hardLock };
|
|
24844
|
+
}
|
|
24845
|
+
function parseState(raw) {
|
|
24846
|
+
if (!isObject3(raw) || raw.version !== 1 || !isCount(raw.lastAcceptedStep) || !isObject3(raw.users)) return void 0;
|
|
24847
|
+
const users = {};
|
|
24848
|
+
for (const [name, entry] of Object.entries(raw.users)) {
|
|
24849
|
+
const u = parseUser(entry);
|
|
24850
|
+
if (!u || !isAgentUser(name)) return void 0;
|
|
24851
|
+
users[name] = u;
|
|
24852
|
+
}
|
|
24853
|
+
return { version: 1, lastAcceptedStep: raw.lastAcceptedStep, users };
|
|
24854
|
+
}
|
|
24855
|
+
function signature(path) {
|
|
24856
|
+
try {
|
|
24857
|
+
const st = statSync(path);
|
|
24858
|
+
return `${st.ino}:${st.mtimeMs}:${st.size}`;
|
|
24859
|
+
} catch (err) {
|
|
24860
|
+
if (err.code === "ENOENT") return "missing";
|
|
24861
|
+
return `error:${err.code}`;
|
|
24862
|
+
}
|
|
24863
|
+
}
|
|
24864
|
+
var OtpStateFile = class {
|
|
24865
|
+
path;
|
|
24866
|
+
now;
|
|
24867
|
+
sig;
|
|
24868
|
+
cached;
|
|
24869
|
+
constructor(path, now = Date.now) {
|
|
24870
|
+
this.path = path;
|
|
24871
|
+
this.now = now;
|
|
24872
|
+
}
|
|
24873
|
+
/** The current state, re-read from disk only when the file changed. */
|
|
24874
|
+
read() {
|
|
24875
|
+
const sig = signature(this.path);
|
|
24876
|
+
if (this.cached && sig === this.sig) return this.cached;
|
|
24877
|
+
this.sig = sig;
|
|
24878
|
+
this.cached = this.load(sig);
|
|
24879
|
+
return this.cached;
|
|
24880
|
+
}
|
|
24881
|
+
load(sig) {
|
|
24882
|
+
if (sig === "missing") return { ok: true, state: emptyState() };
|
|
24883
|
+
let state;
|
|
24884
|
+
let why = "unexpected shape";
|
|
24885
|
+
try {
|
|
24886
|
+
state = parseState(JSON.parse(readFileSync3(this.path, "utf8")));
|
|
24887
|
+
} catch (err) {
|
|
24888
|
+
why = err.message;
|
|
24889
|
+
}
|
|
24890
|
+
if (state) return { ok: true, state };
|
|
24891
|
+
const since = new Date(this.now()).toISOString();
|
|
24892
|
+
console.error(`gh-broker: OTP state file ${this.path} is unreadable or corrupt (${why}); failing closed: every tool except otp_status is locked for every user until "gh-broker otp unlock" rewrites it`);
|
|
24893
|
+
return { ok: false, since };
|
|
24894
|
+
}
|
|
24895
|
+
/**
|
|
24896
|
+
* Persist `state` atomically. On success the cache holds the new state; on failure the
|
|
24897
|
+
* cache still holds it (so in-process counters keep counting) and the error is thrown,
|
|
24898
|
+
* so the caller can refuse anything that depends on the write having landed.
|
|
24899
|
+
*/
|
|
24900
|
+
write(state) {
|
|
24901
|
+
this.cached = { ok: true, state };
|
|
24902
|
+
const tmp = `${this.path}.tmp-${process.pid}-${randomBytes(6).toString("hex")}`;
|
|
24903
|
+
let written;
|
|
24904
|
+
try {
|
|
24905
|
+
const fd = openSync2(tmp, "wx", 384);
|
|
24906
|
+
try {
|
|
24907
|
+
writeSync(fd, `${JSON.stringify(state, null, " ")}
|
|
24908
|
+
`);
|
|
24909
|
+
fsyncSync(fd);
|
|
24910
|
+
const st = fstatSync2(fd);
|
|
24911
|
+
written = `${st.ino}:${st.mtimeMs}:${st.size}`;
|
|
24912
|
+
} finally {
|
|
24913
|
+
closeSync2(fd);
|
|
24914
|
+
}
|
|
24915
|
+
renameSync(tmp, this.path);
|
|
24916
|
+
} catch (err) {
|
|
24917
|
+
try {
|
|
24918
|
+
unlinkSync(tmp);
|
|
24919
|
+
} catch {
|
|
24920
|
+
}
|
|
24921
|
+
this.sig = signature(this.path);
|
|
24922
|
+
throw new Error(`OTP state could not be saved to ${this.path}: ${err.message}`);
|
|
24923
|
+
}
|
|
24924
|
+
this.sig = written;
|
|
24925
|
+
syncDir(dirname2(this.path));
|
|
24926
|
+
}
|
|
24927
|
+
};
|
|
24928
|
+
function syncDir(dir) {
|
|
24929
|
+
try {
|
|
24930
|
+
const fd = openSync2(dir, "r");
|
|
24931
|
+
try {
|
|
24932
|
+
fsyncSync(fd);
|
|
24933
|
+
} finally {
|
|
24934
|
+
closeSync2(fd);
|
|
24935
|
+
}
|
|
24936
|
+
} catch {
|
|
24937
|
+
}
|
|
24938
|
+
}
|
|
24939
|
+
|
|
24940
|
+
// src/otp/gate.ts
|
|
24941
|
+
var OtpRefusal = class extends Error {
|
|
24942
|
+
audit;
|
|
24943
|
+
constructor(message, audit2) {
|
|
24944
|
+
super(message);
|
|
24945
|
+
this.name = "OtpRefusal";
|
|
24946
|
+
this.audit = audit2;
|
|
24947
|
+
}
|
|
24948
|
+
};
|
|
24949
|
+
var UNLOCK_COMMAND = "sudo gh-broker otp unlock";
|
|
24950
|
+
var DAY_MS = 24 * 60 * 60 * 1e3;
|
|
24951
|
+
var UNLOCK_ALL_COMMAND = "sudo gh-broker otp unlock --all";
|
|
24952
|
+
function unlockCommand(user) {
|
|
24953
|
+
return `${UNLOCK_COMMAND} ${user}`;
|
|
24954
|
+
}
|
|
24955
|
+
var OtpGate = class {
|
|
24956
|
+
config;
|
|
24957
|
+
secrets;
|
|
24958
|
+
settings;
|
|
24959
|
+
now;
|
|
24960
|
+
host;
|
|
24961
|
+
state;
|
|
24962
|
+
/** Per-process key for window ids. */
|
|
24963
|
+
idKey = randomBytes2(32);
|
|
24964
|
+
windows = [];
|
|
24965
|
+
/** Users whose latest bad-code count is only in memory because the state file couldn't be written. */
|
|
24966
|
+
unsaved = /* @__PURE__ */ new Set();
|
|
24967
|
+
constructor(config2, secrets, options = {}) {
|
|
24968
|
+
this.config = config2;
|
|
24969
|
+
this.secrets = secrets;
|
|
24970
|
+
this.settings = otpSettings(config2);
|
|
24971
|
+
this.now = options.now ?? Date.now;
|
|
24972
|
+
this.host = options.host ?? hostname();
|
|
24973
|
+
this.state = new OtpStateFile(this.settings.statePath, this.now);
|
|
24974
|
+
}
|
|
24975
|
+
/** Whether the gate runs at all. Without config.otp no state file is ever touched. */
|
|
24976
|
+
get enabled() {
|
|
24977
|
+
return this.config.otp !== void 0;
|
|
24978
|
+
}
|
|
24979
|
+
/**
|
|
24980
|
+
* The hard-lock refusal for `user`, if that user is hard-locked (or the state file is
|
|
24981
|
+
* unusable, which locks everyone). The router checks this on every call except otp_status.
|
|
24982
|
+
*/
|
|
24983
|
+
hardLockRefusal(user) {
|
|
24984
|
+
if (!this.enabled) return void 0;
|
|
24985
|
+
const read = this.state.read();
|
|
24986
|
+
if (!read.ok) {
|
|
24987
|
+
return new OtpRefusal(
|
|
24988
|
+
`gh-broker is locked for every agent: its OTP state file is unreadable or corrupt (since ${read.since}). Ask the user to run "${UNLOCK_ALL_COMMAND}" on ${this.host}. Nothing was run.`,
|
|
24989
|
+
{ otp: "hard-locked" }
|
|
24990
|
+
);
|
|
24991
|
+
}
|
|
24992
|
+
const since = userState(read.state, user).hardLock?.since;
|
|
24993
|
+
if (since === void 0) return void 0;
|
|
24994
|
+
return new OtpRefusal(
|
|
24995
|
+
`gh-broker is locked for ${user} after repeated bad OTP codes (since ${since}). Ask the user to run "${unlockCommand(user)}" on ${this.host}. Nothing was run.`,
|
|
24996
|
+
{ otp: "hard-locked" }
|
|
24997
|
+
);
|
|
24998
|
+
}
|
|
24999
|
+
/**
|
|
25000
|
+
* Decide `user`'s call to the gated tool `tool` carrying `code` (the raw `otp` argument).
|
|
25001
|
+
* Returns the audit fields when the call may run; throws OtpRefusal when it may not.
|
|
25002
|
+
* Only call this after hardLockRefusal() and the availability check passed (OTP is set up).
|
|
25003
|
+
*/
|
|
25004
|
+
authorize(tool, code, user) {
|
|
25005
|
+
const now = this.now();
|
|
25006
|
+
const read = this.state.read();
|
|
25007
|
+
if (!read.ok || userState(read.state, user).hardLock) throw this.hardLockRefusal(user);
|
|
25008
|
+
const state = read.state;
|
|
25009
|
+
const mine = userState(state, user);
|
|
25010
|
+
const lockedUntil = mine.lockedUntil ? Date.parse(mine.lockedUntil) : 0;
|
|
25011
|
+
if (lockedUntil > now) {
|
|
25012
|
+
const minutes = Math.max(1, Math.ceil((lockedUntil - now) / 6e4));
|
|
25013
|
+
throw new OtpRefusal(`OTP is locked for ${minutes} more minute(s) after too many bad codes. Nothing was run.`, { otp: "locked" });
|
|
25014
|
+
}
|
|
25015
|
+
const unsavedRefusal = () => new OtpRefusal(`OTP could not be checked: the broker could not save its OTP state. Nothing was run.`, { otp: "refused" });
|
|
25016
|
+
if (this.unsaved.has(user)) {
|
|
25017
|
+
try {
|
|
25018
|
+
this.state.write(this.compact(state, now));
|
|
25019
|
+
this.unsaved.delete(user);
|
|
25020
|
+
} catch (err) {
|
|
25021
|
+
console.error(`gh-broker: ${err.message}`);
|
|
25022
|
+
throw unsavedRefusal();
|
|
25023
|
+
}
|
|
25024
|
+
}
|
|
25025
|
+
const refusal = () => new OtpRefusal(`OTP required for ${tool}: ask the user for a current code from their authenticator app and pass it as "${OTP_ARG}". Nothing was run.`, { otp: "refused" });
|
|
25026
|
+
if (code === void 0 || code === null || code === "") throw refusal();
|
|
25027
|
+
if (typeof code === "number" && Number.isInteger(code) && code >= 0 && code <= 999999) code = String(code).padStart(6, "0");
|
|
25028
|
+
if (typeof code !== "string") throw refusal();
|
|
25029
|
+
this.windows = this.windows.filter((w) => w.expiresAt > now);
|
|
25030
|
+
let hit;
|
|
25031
|
+
for (const w of this.windows) if (w.user === user && safeEqual(w.code, code)) hit = w;
|
|
25032
|
+
if (hit) return { otp: "window", window: hit.id };
|
|
25033
|
+
const step = matchStep(readSeed(this.config, this.secrets).key, code, now);
|
|
25034
|
+
if (step !== void 0 && step > state.lastAcceptedStep) {
|
|
25035
|
+
try {
|
|
25036
|
+
this.state.write(this.compact({ ...state, lastAcceptedStep: step, users: { ...state.users, [user]: { ...mine, failures: 0 } } }, now));
|
|
25037
|
+
} catch (err) {
|
|
25038
|
+
console.error(`gh-broker: ${err.message}`);
|
|
25039
|
+
throw unsavedRefusal();
|
|
25040
|
+
}
|
|
25041
|
+
const id = createHmac2("sha256", this.idKey).update(`${user}\0${code}`).digest("hex").slice(0, 16);
|
|
25042
|
+
const expiresAt = now + this.settings.windowSeconds * 1e3;
|
|
25043
|
+
this.windows.push({ user, code, expiresAt, id });
|
|
25044
|
+
return { otp: "opened", window: id, until: new Date(expiresAt).toISOString() };
|
|
25045
|
+
}
|
|
25046
|
+
if (step !== void 0) {
|
|
25047
|
+
throw new OtpRefusal(
|
|
25048
|
+
`OTP code for ${tool} is spent (it, or a newer code, was already used): ask the user for the next code from their authenticator app and pass it as "${OTP_ARG}". Nothing was run.`,
|
|
25049
|
+
{ otp: "refused" }
|
|
25050
|
+
);
|
|
25051
|
+
}
|
|
25052
|
+
throw this.rejection(tool, user, this.recordFailure(tool, state, mine, user, now));
|
|
25053
|
+
}
|
|
25054
|
+
/** The refusal for a wrong code, saying how close `user` now is to a lockout. */
|
|
25055
|
+
rejection(tool, user, after) {
|
|
25056
|
+
const lead = `OTP code for ${tool} was not accepted`;
|
|
25057
|
+
if (after.hardLock) {
|
|
25058
|
+
return new OtpRefusal(`${lead}, and that was the last lockout allowed: gh-broker is now locked for ${user}. Ask the user to run "${unlockCommand(user)}" on ${this.host}. Nothing was run.`, {
|
|
25059
|
+
otp: "refused"
|
|
25060
|
+
});
|
|
25061
|
+
}
|
|
25062
|
+
const lockout = windowText(this.settings.lockoutSeconds);
|
|
25063
|
+
if (after.failures === 0) return new OtpRefusal(`${lead}, and that was too many bad codes: OTP is locked for ${lockout}. Nothing was run.`, { otp: "refused" });
|
|
25064
|
+
return new OtpRefusal(
|
|
25065
|
+
`${lead} (${after.failures} of ${this.settings.maxFailures} bad codes before a lockout of ${lockout}): ask the user for a fresh code from their authenticator app and pass it as "${OTP_ARG}". Nothing was run.`,
|
|
25066
|
+
{ otp: "refused" }
|
|
25067
|
+
);
|
|
25068
|
+
}
|
|
25069
|
+
/** Count one bad code for `user`, starting a short lockout (and maybe the hard lock) at the limit. Returns the user's new counters. */
|
|
25070
|
+
recordFailure(tool, state, mine, user, now) {
|
|
25071
|
+
const next = { ...mine, lockouts: mine.lockouts.filter((t) => Date.parse(t) > now - DAY_MS) };
|
|
25072
|
+
next.failures += 1;
|
|
25073
|
+
if (next.failures >= this.settings.maxFailures) {
|
|
25074
|
+
const at = new Date(now).toISOString();
|
|
25075
|
+
next.failures = 0;
|
|
25076
|
+
next.lockedUntil = new Date(now + this.settings.lockoutSeconds * 1e3).toISOString();
|
|
25077
|
+
next.lockouts.push(at);
|
|
25078
|
+
if (next.lockouts.length >= this.settings.maxLockouts) next.hardLock = { since: at, lockouts: next.lockouts.length };
|
|
25079
|
+
audit(
|
|
25080
|
+
next.hardLock ? { tool, user, decision: "deny", otp: "hard-lock", detail: `${next.lockouts.length} lockouts in 24 h; every tool except otp_status is refused until "${unlockCommand(user)}"` } : { tool, user, decision: "deny", otp: "lockout", until: next.lockedUntil, detail: `${this.settings.maxFailures} bad codes` }
|
|
25081
|
+
);
|
|
25082
|
+
}
|
|
25083
|
+
try {
|
|
25084
|
+
this.state.write(this.compact({ ...state, users: { ...state.users, [user]: next } }, now));
|
|
25085
|
+
} catch (err) {
|
|
25086
|
+
this.unsaved.add(user);
|
|
25087
|
+
console.error(`gh-broker: ${err.message}`);
|
|
25088
|
+
}
|
|
25089
|
+
return next;
|
|
25090
|
+
}
|
|
25091
|
+
/**
|
|
25092
|
+
* `state` tidied for a write: every user's lockouts pruned to the last 24 h, expired short
|
|
25093
|
+
* lockouts cleared, and users left with nothing to record dropped.
|
|
25094
|
+
*/
|
|
25095
|
+
compact(state, now) {
|
|
25096
|
+
const users = {};
|
|
25097
|
+
for (const [name, u] of Object.entries(state.users)) {
|
|
25098
|
+
const lockouts = u.lockouts.filter((t) => Date.parse(t) > now - DAY_MS);
|
|
25099
|
+
const lockedUntil = u.lockedUntil && Date.parse(u.lockedUntil) > now ? u.lockedUntil : null;
|
|
25100
|
+
if (u.failures === 0 && lockedUntil === null && lockouts.length === 0 && u.hardLock === null) continue;
|
|
25101
|
+
users[name] = { failures: u.failures, lockedUntil, lockouts, hardLock: u.hardLock };
|
|
25102
|
+
}
|
|
25103
|
+
return { ...state, users };
|
|
25104
|
+
}
|
|
25105
|
+
/**
|
|
25106
|
+
* The otp_status report for `user`: their own windows and counters, plus every hard-locked
|
|
25107
|
+
* user. Never a code, the seed, or anything derived from them.
|
|
25108
|
+
*/
|
|
25109
|
+
status(user) {
|
|
25110
|
+
const now = this.now();
|
|
25111
|
+
const gatedTools = this.config.tools.filter((t) => isGated(t) && isEnabled(t)).map((t) => t.name);
|
|
25112
|
+
let mine = emptyUser();
|
|
25113
|
+
let hardLock = null;
|
|
25114
|
+
let lockedUsers = [];
|
|
25115
|
+
let corrupt = false;
|
|
25116
|
+
if (this.enabled) {
|
|
25117
|
+
const read = this.state.read();
|
|
25118
|
+
if (read.ok) {
|
|
25119
|
+
mine = userState(read.state, user);
|
|
25120
|
+
hardLock = mine.hardLock;
|
|
25121
|
+
lockedUsers = Object.entries(read.state.users).filter(([, u]) => u.hardLock).map(([name, u]) => ({ user: name, since: u.hardLock.since, lockouts: u.hardLock.lockouts, unlockCommand: unlockCommand(name) }));
|
|
25122
|
+
} else {
|
|
25123
|
+
hardLock = { since: read.since, lockouts: 0 };
|
|
25124
|
+
corrupt = true;
|
|
25125
|
+
lockedUsers = [{ user, since: read.since, lockouts: 0, unlockCommand: UNLOCK_ALL_COMMAND }];
|
|
25126
|
+
}
|
|
25127
|
+
}
|
|
25128
|
+
const open2 = this.windows.filter((w) => w.user === user && w.expiresAt > now);
|
|
25129
|
+
const next = open2.length ? Math.min(...open2.map((w) => w.expiresAt)) : void 0;
|
|
25130
|
+
const lockedUntil = mine.lockedUntil && Date.parse(mine.lockedUntil) > now ? mine.lockedUntil : null;
|
|
25131
|
+
return {
|
|
25132
|
+
configured: !("problem" in readSeed(this.config, this.secrets)),
|
|
25133
|
+
user,
|
|
25134
|
+
gatedTools,
|
|
25135
|
+
windowSeconds: this.settings.windowSeconds,
|
|
25136
|
+
openWindows: open2.length,
|
|
25137
|
+
nextWindowExpiresAt: next === void 0 ? null : new Date(next).toISOString(),
|
|
25138
|
+
failures: mine.failures,
|
|
25139
|
+
maxFailures: this.settings.maxFailures,
|
|
25140
|
+
lockedUntil,
|
|
25141
|
+
lockoutsLast24h: mine.lockouts.filter((t) => Date.parse(t) > now - DAY_MS).length,
|
|
25142
|
+
maxLockouts: this.settings.maxLockouts,
|
|
25143
|
+
hardLock,
|
|
25144
|
+
unlockCommand: corrupt ? UNLOCK_ALL_COMMAND : unlockCommand(user),
|
|
25145
|
+
lockedUsers,
|
|
25146
|
+
host: this.host
|
|
25147
|
+
};
|
|
25148
|
+
}
|
|
25149
|
+
};
|
|
25150
|
+
function windowText(seconds) {
|
|
25151
|
+
if (seconds % 60 === 0) return `${seconds / 60} minute${seconds === 60 ? "" : "s"}`;
|
|
25152
|
+
return `${seconds} seconds`;
|
|
25153
|
+
}
|
|
25154
|
+
|
|
24403
25155
|
// src/router.ts
|
|
24404
25156
|
var GHCR_TOOLS2 = /* @__PURE__ */ new Set(["ghcr_push", "ghcr_push_status", "ghcr_tags"]);
|
|
24405
25157
|
var Router = class {
|
|
@@ -24410,65 +25162,110 @@ var Router = class {
|
|
|
24410
25162
|
rules;
|
|
24411
25163
|
/** GHCR verbs run on config.ghcr's own credential, not the owner's GitHub PAT (created on first use). */
|
|
24412
25164
|
ghcr;
|
|
24413
|
-
|
|
25165
|
+
/** The OTP gate (#143). Inert without config.otp. */
|
|
25166
|
+
otp;
|
|
25167
|
+
constructor(config2, secrets, upstream, appAuth, otp) {
|
|
24414
25168
|
this.config = config2;
|
|
24415
25169
|
this.secrets = secrets;
|
|
24416
25170
|
this.upstream = upstream;
|
|
24417
25171
|
this.appAuth = appAuth;
|
|
24418
25172
|
this.rules = indexTools(config2.tools);
|
|
25173
|
+
this.otp = otp ?? new OtpGate(config2, secrets);
|
|
24419
25174
|
}
|
|
24420
25175
|
/** Tools advertised to the agent (used to build tools/list). */
|
|
24421
25176
|
advertisedTools() {
|
|
24422
25177
|
return this.config.tools.filter((rule) => !unavailableReason(rule, this.config, this.secrets));
|
|
24423
25178
|
}
|
|
24424
|
-
|
|
25179
|
+
/** Run `tool` for `caller`, the agent user whose socket the call came in on. */
|
|
25180
|
+
async call(tool, rawArgs, caller) {
|
|
25181
|
+
const user = caller.user;
|
|
24425
25182
|
const rule = this.rules.get(tool);
|
|
24426
25183
|
if (!rule) {
|
|
24427
|
-
audit({ tool, decision: "deny", detail: "not in allowlist" });
|
|
25184
|
+
audit({ tool, user, decision: "deny", detail: "not in allowlist" });
|
|
24428
25185
|
throw new Error(`tool "${tool}" is not allowed`);
|
|
24429
25186
|
}
|
|
25187
|
+
if (tool !== OTP_STATUS) {
|
|
25188
|
+
const locked = this.otp.hardLockRefusal(user);
|
|
25189
|
+
if (locked) {
|
|
25190
|
+
audit({ tool, user, decision: "deny", detail: "otp hard lock", ...locked.audit });
|
|
25191
|
+
throw locked;
|
|
25192
|
+
}
|
|
25193
|
+
}
|
|
25194
|
+
let args = rawArgs;
|
|
25195
|
+
let code;
|
|
25196
|
+
if (Object.hasOwn(rawArgs, OTP_ARG)) {
|
|
25197
|
+
const { [OTP_ARG]: given, ...rest } = rawArgs;
|
|
25198
|
+
code = given;
|
|
25199
|
+
args = rest;
|
|
25200
|
+
}
|
|
24430
25201
|
const unavailable = unavailableReason(rule, this.config, this.secrets);
|
|
24431
25202
|
if (unavailable) {
|
|
24432
|
-
audit({ tool, decision: "deny", detail: `unavailable: ${unavailable}` });
|
|
25203
|
+
audit({ tool, user, decision: "deny", detail: `unavailable: ${unavailable}` });
|
|
24433
25204
|
throw new Error(`tool "${tool}" is not available on this broker: ${unavailable}`);
|
|
24434
25205
|
}
|
|
24435
|
-
if (
|
|
24436
|
-
|
|
25206
|
+
if (tool === OTP_STATUS) {
|
|
25207
|
+
audit({ tool, user, mode: rule.mode, decision: "custom", ok: true });
|
|
25208
|
+
return { content: [{ type: "text", text: JSON.stringify(this.otp.status(user)) }] };
|
|
25209
|
+
}
|
|
25210
|
+
let createdIn;
|
|
25211
|
+
if (REPO_CREATING.has(tool) || tool === "actions_run_trigger") {
|
|
25212
|
+
try {
|
|
25213
|
+
if (tool === "actions_run_trigger") refuseLogDeletion(args);
|
|
25214
|
+
else createdIn = creationOwner(tool, args, this.config.owners);
|
|
25215
|
+
} catch (err) {
|
|
25216
|
+
const requested = typeof args.organization === "string" ? args.organization : extractOwner(args);
|
|
25217
|
+
audit({ tool, user, mode: rule.mode, ...requested ? { owner: requested.toLowerCase() } : {}, decision: "error", detail: err.message });
|
|
25218
|
+
throw err;
|
|
25219
|
+
}
|
|
25220
|
+
}
|
|
25221
|
+
let otp;
|
|
25222
|
+
if (isGated(rule)) {
|
|
25223
|
+
try {
|
|
25224
|
+
otp = this.otp.authorize(tool, code, user);
|
|
25225
|
+
} catch (err) {
|
|
25226
|
+
audit({ tool, user, decision: "deny", detail: "otp", ...err.audit });
|
|
25227
|
+
throw err;
|
|
25228
|
+
}
|
|
25229
|
+
}
|
|
25230
|
+
if (GHCR_TOOLS2.has(tool)) return this.callGhcr(tool, args, rule, user, otp);
|
|
25231
|
+
let owner2 = createdIn ?? extractOwner(args);
|
|
24437
25232
|
if (!owner2 && rule.mode === "read" && this.config.defaultOwner && this.config.owners[this.config.defaultOwner]) {
|
|
24438
25233
|
owner2 = this.config.defaultOwner.toLowerCase();
|
|
24439
25234
|
}
|
|
24440
25235
|
if (!owner2) {
|
|
24441
|
-
audit({ tool, decision: "error", detail: "no owner in args" });
|
|
25236
|
+
audit({ tool, user, decision: "error", detail: "no owner in args" });
|
|
24442
25237
|
throw new Error(`tool "${tool}": could not determine repo owner from arguments`);
|
|
24443
25238
|
}
|
|
24444
25239
|
const creds = this.config.owners[owner2];
|
|
24445
25240
|
if (!creds) {
|
|
24446
|
-
audit({ tool, owner: owner2, decision: "error", detail: "no creds for owner" });
|
|
25241
|
+
audit({ tool, user, owner: owner2, decision: "error", detail: "no creds for owner" });
|
|
24447
25242
|
throw new Error(`no credentials configured for owner "${owner2}" \u2014 add it to config.owners + creds`);
|
|
24448
25243
|
}
|
|
24449
25244
|
const token = this.secrets.resolve(rule.mode === "write" ? creds.write : creds.read);
|
|
24450
|
-
const
|
|
25245
|
+
const repoName = tool === "create_repository" ? args.name : args.repo;
|
|
25246
|
+
const repo = typeof repoName === "string" ? `${owner2}/${repoName}` : owner2;
|
|
25247
|
+
const forked = tool === "fork_repository" ? { source: `${String(args.owner)}/${String(args.repo)}` } : {};
|
|
24451
25248
|
const decision = rule.custom ? "custom" : rule.augment ? "augment" : "forward";
|
|
24452
25249
|
try {
|
|
24453
25250
|
const out = rule.custom ? await this.dispatchCustom(tool, args, owner2, token) : rule.augment ? await this.dispatchAugmented(tool, args, owner2, token) : await this.upstream.forward(tool, args, token);
|
|
24454
|
-
audit({ tool, owner: owner2, mode: rule.mode, repo, decision, ok: true });
|
|
25251
|
+
audit({ tool, user, owner: owner2, mode: rule.mode, repo, ...forked, decision, ok: true, ...otp });
|
|
24455
25252
|
return out;
|
|
24456
25253
|
} catch (err) {
|
|
24457
|
-
audit({ tool, owner: owner2, mode: rule.mode, repo, decision, ok: false, detail: err.message });
|
|
25254
|
+
audit({ tool, user, owner: owner2, mode: rule.mode, repo, ...forked, decision, ok: false, detail: err.message, ...otp });
|
|
24458
25255
|
throw err;
|
|
24459
25256
|
}
|
|
24460
25257
|
}
|
|
24461
25258
|
/** GHCR verbs: config.ghcr checks the owner and image itself; no GitHub PAT is resolved. */
|
|
24462
|
-
async callGhcr(tool, args, rule) {
|
|
25259
|
+
async callGhcr(tool, args, rule, user, otp) {
|
|
24463
25260
|
const owner2 = extractOwner(args) ?? "";
|
|
24464
25261
|
const repo = typeof args.image === "string" ? `${owner2}/${args.image}` : owner2;
|
|
24465
25262
|
try {
|
|
24466
25263
|
this.ghcr ??= new GhcrService(this.config.ghcr, this.secrets);
|
|
24467
25264
|
const out = tool === "ghcr_push" ? await this.ghcr.push(args) : tool === "ghcr_tags" ? await this.ghcr.tags(args) : this.ghcr.status(args);
|
|
24468
|
-
audit({ tool, owner: owner2, mode: rule.mode, repo, decision: "custom", ok: true });
|
|
25265
|
+
audit({ tool, user, owner: owner2, mode: rule.mode, repo, decision: "custom", ok: true, ...otp });
|
|
24469
25266
|
return { content: [{ type: "text", text: JSON.stringify(out) }] };
|
|
24470
25267
|
} catch (err) {
|
|
24471
|
-
audit({ tool, owner: owner2, mode: rule.mode, repo, decision: "custom", ok: false, detail: err.message });
|
|
25268
|
+
audit({ tool, user, owner: owner2, mode: rule.mode, repo, decision: "custom", ok: false, detail: err.message, ...otp });
|
|
24472
25269
|
throw err;
|
|
24473
25270
|
}
|
|
24474
25271
|
}
|
|
@@ -24543,6 +25340,18 @@ var Router = class {
|
|
|
24543
25340
|
const out = await npmScaffold(args, { octokit, registry: registry2, scopes: npm.scopes });
|
|
24544
25341
|
return { content: [{ type: "text", text: JSON.stringify(out) }] };
|
|
24545
25342
|
}
|
|
25343
|
+
case "minimize_comment": {
|
|
25344
|
+
const out = await minimizeComment(new Octokit({ auth: token }), args);
|
|
25345
|
+
return { content: [{ type: "text", text: JSON.stringify(out) }] };
|
|
25346
|
+
}
|
|
25347
|
+
case "delete_branch": {
|
|
25348
|
+
const out = await deleteBranch(new Octokit({ auth: token }), args);
|
|
25349
|
+
return { content: [{ type: "text", text: JSON.stringify(out) }] };
|
|
25350
|
+
}
|
|
25351
|
+
case "dismiss_alert": {
|
|
25352
|
+
const out = await dismissAlert(new Octokit({ auth: token }), args);
|
|
25353
|
+
return { content: [{ type: "text", text: JSON.stringify(out) }] };
|
|
25354
|
+
}
|
|
24546
25355
|
default:
|
|
24547
25356
|
throw new Error(`custom verb "${tool}" not implemented`);
|
|
24548
25357
|
}
|
|
@@ -24649,6 +25458,21 @@ var Router = class {
|
|
|
24649
25458
|
}
|
|
24650
25459
|
}
|
|
24651
25460
|
};
|
|
25461
|
+
var REPO_CREATING = /* @__PURE__ */ new Set(["create_repository", "fork_repository"]);
|
|
25462
|
+
function creationOwner(tool, args, owners) {
|
|
25463
|
+
if (typeof args.organization !== "string" || !args.organization) {
|
|
25464
|
+
throw new Error(`tool "${tool}": "organization" is required (the org the repo is created in); gh-broker doesn't create repos in a personal account`);
|
|
25465
|
+
}
|
|
25466
|
+
const org = args.organization.toLowerCase();
|
|
25467
|
+
if (!owners[org]) throw new Error(`no credentials configured for owner "${org}" \u2014 add it to config.owners + creds`);
|
|
25468
|
+
if (tool === "create_repository" && typeof args.private !== "boolean") {
|
|
25469
|
+
throw new Error(`tool "create_repository": "private" is required, true or false; a repo's visibility is never left to a default`);
|
|
25470
|
+
}
|
|
25471
|
+
return org;
|
|
25472
|
+
}
|
|
25473
|
+
function refuseLogDeletion(args) {
|
|
25474
|
+
if (args.method === "delete_workflow_run_logs") throw new Error(`tool "actions_run_trigger": method "delete_workflow_run_logs" is not allowed in gh-broker; a run's logs are kept`);
|
|
25475
|
+
}
|
|
24652
25476
|
function extractOwner(args) {
|
|
24653
25477
|
if (typeof args.owner === "string" && args.owner) return args.owner.toLowerCase();
|
|
24654
25478
|
if (typeof args.org === "string" && args.org) return args.org.toLowerCase();
|
|
@@ -26016,6 +26840,14 @@ var AppAuth = class {
|
|
|
26016
26840
|
}
|
|
26017
26841
|
};
|
|
26018
26842
|
|
|
26843
|
+
// src/verbs/otp_status.ts
|
|
26844
|
+
var OTP_STATUS_SCHEMA = {
|
|
26845
|
+
type: "object",
|
|
26846
|
+
additionalProperties: false,
|
|
26847
|
+
properties: {}
|
|
26848
|
+
};
|
|
26849
|
+
var OTP_STATUS_DESCRIPTION = `Report the broker's OTP gate: whether OTP is set up, which tools need an "otp" code, how many windows are open and when the next one closes, bad codes so far, any short lockout, short lockouts in the last 24 h, and the hard lock (which blocks every other tool) with the command that clears it. Never shows a code or the seed. Works even while the broker is locked. Needs no owner.`;
|
|
26850
|
+
|
|
26019
26851
|
// src/catalog.ts
|
|
26020
26852
|
async function buildCatalog(config2, secrets, upstream) {
|
|
26021
26853
|
const upstreamByName = /* @__PURE__ */ new Map();
|
|
@@ -26064,7 +26896,7 @@ async function buildCatalog(config2, secrets, upstream) {
|
|
|
26064
26896
|
} else if (rule.name === "npm_scaffold") {
|
|
26065
26897
|
tools.push({
|
|
26066
26898
|
name: "npm_scaffold",
|
|
26067
|
-
description: "Create a new npm package and register its GitHub Actions trusted publisher, so the repo's publish workflow can release it through OIDC from then on. Publishes a broker-built placeholder (0.0.0-stage, no code) and never publishes a version of an existing package. Every npm write needs its own one-time password from the user, so it takes two calls: the first publishes the placeholder and returns status needs_otp; ask the user for the next code and call again to add the trusted publisher. Ask for each code right before the call. A refused or expired code also returns needs_otp with the remaining steps. Checks first that the repo's package.json names the package and that the workflow file exists. Uses the broker's npm token (no 2FA bypass) and the read PAT.",
|
|
26899
|
+
description: "Create a new npm package and register its GitHub Actions trusted publisher, so the repo's publish workflow can release it through OIDC from then on. Publishes a broker-built placeholder (0.0.0-stage, no code) and never publishes a version of an existing package. Every npm write needs its own one-time password from the user, passed as npm_otp, so it takes two calls: the first publishes the placeholder and returns status needs_otp; ask the user for the next npm code and call again with it in npm_otp to add the trusted publisher. Ask for each code right before the call. A refused or expired code also returns needs_otp with the remaining steps. Checks first that the repo's package.json names the package and that the workflow file exists. Uses the broker's npm token (no 2FA bypass) and the read PAT.",
|
|
26068
26900
|
inputSchema: NPM_SCAFFOLD_SCHEMA
|
|
26069
26901
|
});
|
|
26070
26902
|
} else if (rule.name === "report_unlabeled") {
|
|
@@ -26109,6 +26941,14 @@ async function buildCatalog(config2, secrets, upstream) {
|
|
|
26109
26941
|
description: "Report CI health for a PR, or every open PR in a repo, at a chosen verbosity: 0 counts only, 1 adds which run/job failed, 2 adds which step failed, 3 adds a tail of the failing job's log. Detail beyond tier 0 is only fetched for runs that are actually failing. Runs on the read PAT.",
|
|
26110
26942
|
inputSchema: REPORT_CI_STATUS_SCHEMA
|
|
26111
26943
|
});
|
|
26944
|
+
} else if (rule.name === "minimize_comment") {
|
|
26945
|
+
tools.push({ name: "minimize_comment", description: MINIMIZE_COMMENT_DESCRIPTION, inputSchema: MINIMIZE_COMMENT_SCHEMA });
|
|
26946
|
+
} else if (rule.name === "delete_branch") {
|
|
26947
|
+
tools.push({ name: "delete_branch", description: DELETE_BRANCH_DESCRIPTION, inputSchema: DELETE_BRANCH_SCHEMA });
|
|
26948
|
+
} else if (rule.name === "dismiss_alert") {
|
|
26949
|
+
tools.push({ name: "dismiss_alert", description: DISMISS_ALERT_DESCRIPTION, inputSchema: DISMISS_ALERT_SCHEMA });
|
|
26950
|
+
} else if (rule.name === OTP_STATUS) {
|
|
26951
|
+
tools.push({ name: OTP_STATUS, description: OTP_STATUS_DESCRIPTION, inputSchema: OTP_STATUS_SCHEMA });
|
|
26112
26952
|
} else if (GHCR_CATALOG[rule.name]) {
|
|
26113
26953
|
tools.push({ name: rule.name, ...GHCR_CATALOG[rule.name] });
|
|
26114
26954
|
}
|
|
@@ -26154,6 +26994,21 @@ async function buildCatalog(config2, secrets, upstream) {
|
|
|
26154
26994
|
});
|
|
26155
26995
|
continue;
|
|
26156
26996
|
}
|
|
26997
|
+
if (rule.name === "create_repository" || rule.name === "fork_repository") {
|
|
26998
|
+
const schema = base.inputSchema;
|
|
26999
|
+
const needed = rule.name === "create_repository" ? ["organization", "private"] : ["organization"];
|
|
27000
|
+
const note = rule.name === "create_repository" ? ' gh-broker: "organization" and "private" are required; the repo is never created in a personal account or left public by default.' : ' gh-broker: "organization" (where the fork goes) is required; forks are never created in a personal account.';
|
|
27001
|
+
tools.push({
|
|
27002
|
+
...base,
|
|
27003
|
+
description: `${base.description ?? ""}${note}`.trim(),
|
|
27004
|
+
inputSchema: { ...schema, type: "object", required: [.../* @__PURE__ */ new Set([...schema.required ?? [], ...needed])] }
|
|
27005
|
+
});
|
|
27006
|
+
continue;
|
|
27007
|
+
}
|
|
27008
|
+
if (rule.name === "actions_run_trigger") {
|
|
27009
|
+
tools.push({ ...base, description: `${base.description ?? ""} gh-broker: method "delete_workflow_run_logs" is refused; a run's logs are kept.`.trim() });
|
|
27010
|
+
continue;
|
|
27011
|
+
}
|
|
26157
27012
|
if (rule.name === "issue_write") {
|
|
26158
27013
|
const pointer = config2.tools.some((t) => t.name === "issue_labels" && t.custom) ? " To add, remove or swap individual labels, use issue_labels instead." : " Read the current labels first and pass the full set you want to keep.";
|
|
26159
27014
|
tools.push({
|
|
@@ -26164,12 +27019,34 @@ async function buildCatalog(config2, secrets, upstream) {
|
|
|
26164
27019
|
}
|
|
26165
27020
|
tools.push(base);
|
|
26166
27021
|
}
|
|
26167
|
-
return tools;
|
|
27022
|
+
return withOtp(tools, config2);
|
|
27023
|
+
}
|
|
27024
|
+
function withOtp(tools, config2) {
|
|
27025
|
+
const gated = new Set(config2.tools.filter(isGated).map((t) => t.name));
|
|
27026
|
+
const window = windowText(otpSettings(config2).windowSeconds);
|
|
27027
|
+
return tools.map((tool) => {
|
|
27028
|
+
if (!gated.has(tool.name)) return tool;
|
|
27029
|
+
const schema = tool.inputSchema;
|
|
27030
|
+
return {
|
|
27031
|
+
...tool,
|
|
27032
|
+
description: `${tool.description ?? ""} Needs a broker OTP (gh-broker): ask the user for a current code from their authenticator app and pass it as "${OTP_ARG}". One code works for every gated tool for ${window} after the first call that uses it.`.trim(),
|
|
27033
|
+
inputSchema: {
|
|
27034
|
+
...schema,
|
|
27035
|
+
type: "object",
|
|
27036
|
+
properties: {
|
|
27037
|
+
...schema.properties ?? {},
|
|
27038
|
+
[OTP_ARG]: { type: "string", description: "gh-broker: a current 6-digit code from the user's authenticator app, or the code that opened the current window" }
|
|
27039
|
+
},
|
|
27040
|
+
required: [...(schema.required ?? []).filter((r) => r !== OTP_ARG), OTP_ARG]
|
|
27041
|
+
}
|
|
27042
|
+
};
|
|
27043
|
+
});
|
|
26168
27044
|
}
|
|
26169
27045
|
|
|
26170
27046
|
// src/server.ts
|
|
26171
27047
|
import { createServer } from "node:net";
|
|
26172
|
-
import { chmodSync, chownSync, existsSync, readFileSync as
|
|
27048
|
+
import { chmodSync, chownSync, existsSync, mkdirSync, readdirSync, readFileSync as readFileSync4, rmSync, unlinkSync as unlinkSync2 } from "node:fs";
|
|
27049
|
+
import { join as join4 } from "node:path";
|
|
26173
27050
|
|
|
26174
27051
|
// node_modules/@modelcontextprotocol/sdk/dist/esm/experimental/tasks/server.js
|
|
26175
27052
|
var ExperimentalServerTasks = class {
|
|
@@ -26864,26 +27741,12 @@ var StdioServerTransport = class {
|
|
|
26864
27741
|
|
|
26865
27742
|
// src/server.ts
|
|
26866
27743
|
var SERVER_INFO = { name: "gh-broker", version: "0.1.0" };
|
|
26867
|
-
function
|
|
26868
|
-
if (!listen.startsWith("unix:")) throw new Error(`server: unsupported listen "${listen}"`);
|
|
26869
|
-
return listen.slice("unix:".length);
|
|
26870
|
-
}
|
|
26871
|
-
function groupGid(name) {
|
|
26872
|
-
try {
|
|
26873
|
-
for (const line of readFileSync3("/etc/group", "utf8").split("\n")) {
|
|
26874
|
-
const parts = line.split(":");
|
|
26875
|
-
if (parts[0] === name && parts[2]) return Number(parts[2]);
|
|
26876
|
-
}
|
|
26877
|
-
} catch {
|
|
26878
|
-
}
|
|
26879
|
-
return void 0;
|
|
26880
|
-
}
|
|
26881
|
-
function handleConnection(socket, router, catalog) {
|
|
27744
|
+
function handleConnection(socket, router, catalog, user) {
|
|
26882
27745
|
const server = new Server(SERVER_INFO, { capabilities: { tools: {} } });
|
|
26883
27746
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: catalog }));
|
|
26884
27747
|
server.setRequestHandler(CallToolRequestSchema, async (req2) => {
|
|
26885
27748
|
const args = req2.params.arguments ?? {};
|
|
26886
|
-
return router.call(req2.params.name, args);
|
|
27749
|
+
return router.call(req2.params.name, args, { user });
|
|
26887
27750
|
});
|
|
26888
27751
|
const transport = new StdioServerTransport(socket, socket);
|
|
26889
27752
|
server.connect(transport).catch((err) => {
|
|
@@ -26895,24 +27758,59 @@ function handleConnection(socket, router, catalog) {
|
|
|
26895
27758
|
socket.on("error", () => {
|
|
26896
27759
|
});
|
|
26897
27760
|
}
|
|
26898
|
-
function
|
|
26899
|
-
|
|
26900
|
-
|
|
27761
|
+
function readAgents(groupFile) {
|
|
27762
|
+
let text;
|
|
27763
|
+
try {
|
|
27764
|
+
text = readFileSync4(groupFile, "utf8");
|
|
27765
|
+
} catch (err) {
|
|
27766
|
+
console.error(`gh-broker: cannot read ${groupFile} (${err.message}); serving no agents`);
|
|
27767
|
+
return [];
|
|
27768
|
+
}
|
|
27769
|
+
const { agents, skipped } = parseAgentGroups(text);
|
|
27770
|
+
for (const why of skipped) console.error(`gh-broker: ignoring agent group ${why}`);
|
|
27771
|
+
return agents;
|
|
27772
|
+
}
|
|
27773
|
+
function listenFor(path, agent, router, catalog) {
|
|
27774
|
+
if (existsSync(path)) unlinkSync2(path);
|
|
26901
27775
|
return new Promise((resolve, reject) => {
|
|
26902
|
-
const server = createServer((sock) => handleConnection(sock, router, catalog));
|
|
27776
|
+
const server = createServer((sock) => handleConnection(sock, router, catalog, agent.user));
|
|
26903
27777
|
server.on("error", reject);
|
|
26904
27778
|
server.listen(path, () => {
|
|
26905
|
-
const group = process.env.GH_BROKER_SOCKET_GROUP ?? "gh-broker-clients";
|
|
26906
|
-
const gid = groupGid(group);
|
|
26907
27779
|
try {
|
|
26908
|
-
|
|
27780
|
+
chownSync(path, process.getuid?.() ?? -1, agent.gid);
|
|
27781
|
+
} catch (err) {
|
|
27782
|
+
console.error(`gh-broker: cannot give ${path} to group ${agent.group} (${err.message}); ${agent.user} can't connect`);
|
|
27783
|
+
}
|
|
27784
|
+
try {
|
|
26909
27785
|
chmodSync(path, 432);
|
|
26910
|
-
} catch {
|
|
27786
|
+
} catch (err) {
|
|
27787
|
+
server.close();
|
|
27788
|
+
reject(err);
|
|
27789
|
+
return;
|
|
26911
27790
|
}
|
|
26912
27791
|
resolve();
|
|
26913
27792
|
});
|
|
26914
27793
|
});
|
|
26915
27794
|
}
|
|
27795
|
+
async function startServer(config2, router, catalog, options = {}) {
|
|
27796
|
+
const dir = agentsDir(config2.listen);
|
|
27797
|
+
if (!dir) throw new Error(`server: unsupported listen "${config2.listen}" (expected "agents:<absolute dir>")`);
|
|
27798
|
+
const agents = readAgents(options.groupFile ?? "/etc/group");
|
|
27799
|
+
if (!existsSync(dir)) {
|
|
27800
|
+
mkdirSync(dir, { recursive: true });
|
|
27801
|
+
chmodSync(dir, 493);
|
|
27802
|
+
}
|
|
27803
|
+
const wanted = new Set(agents.map((a) => `${a.user}.sock`));
|
|
27804
|
+
for (const name of readdirSync(dir)) {
|
|
27805
|
+
if (name.endsWith(".sock") && !wanted.has(name)) rmSync(join4(dir, name), { force: true });
|
|
27806
|
+
}
|
|
27807
|
+
if (agents.length === 0) {
|
|
27808
|
+
console.error("gh-broker: no agents registered (no gh-broker-agent-<user> group with members gh-broker and <user>); serving nothing. Add one with: sudo gh-broker agent add <user>");
|
|
27809
|
+
return [];
|
|
27810
|
+
}
|
|
27811
|
+
for (const agent of agents) await listenFor(join4(dir, `${agent.user}.sock`), agent, router, catalog);
|
|
27812
|
+
return agents.map((a) => a.user);
|
|
27813
|
+
}
|
|
26916
27814
|
|
|
26917
27815
|
// src/index.ts
|
|
26918
27816
|
init_paths();
|
|
@@ -26958,12 +27856,13 @@ async function main() {
|
|
|
26958
27856
|
}
|
|
26959
27857
|
const router = new Router(config2, secrets, upstream, appAuth);
|
|
26960
27858
|
const catalog = await buildCatalog(config2, secrets, upstream);
|
|
26961
|
-
await startServer(config2, router, catalog);
|
|
27859
|
+
const agents = await startServer(config2, router, catalog);
|
|
26962
27860
|
console.log(
|
|
26963
27861
|
JSON.stringify({
|
|
26964
27862
|
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
26965
27863
|
event: "listening",
|
|
26966
27864
|
listen: config2.listen,
|
|
27865
|
+
agents,
|
|
26967
27866
|
owners: Object.keys(config2.owners),
|
|
26968
27867
|
app: appAuth ? "enabled" : "none",
|
|
26969
27868
|
tools: catalog.map((t) => t.name)
|