@snowyroad/braid 0.97.0 → 0.98.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 +23 -1
- package/dist/{chunk-2OHYN3KD.js → chunk-3SHZYLWH.js} +112 -4
- package/dist/cli.js +1297 -129
- package/dist/{http-7LMCYNHB.js → http-7QNDIXBN.js} +1 -1
- package/dist/mcp/server.js +120 -2
- package/package.json +1 -1
- package/service-shrinkwrap.json +2 -2
package/dist/cli.js
CHANGED
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
SourceBroker,
|
|
16
16
|
WS_MAX_PAYLOAD_BYTES,
|
|
17
17
|
acquireAgentLock,
|
|
18
|
+
applyWin32Acl,
|
|
18
19
|
assembleHumanFacts,
|
|
19
20
|
assembleRosterFacts,
|
|
20
21
|
assertKeystorePerms,
|
|
@@ -59,6 +60,7 @@ import {
|
|
|
59
60
|
parseCardReply,
|
|
60
61
|
parseRunFlags,
|
|
61
62
|
pinEndpoint,
|
|
63
|
+
productionWin32Deps,
|
|
62
64
|
readPathWithFallback,
|
|
63
65
|
redeemInvite,
|
|
64
66
|
relayHostSegment,
|
|
@@ -66,6 +68,7 @@ import {
|
|
|
66
68
|
resolveAgentSelection,
|
|
67
69
|
resolveClaudeBinary,
|
|
68
70
|
resolveConfig,
|
|
71
|
+
resolveCurrentUser,
|
|
69
72
|
resolveIpcProfile,
|
|
70
73
|
resolveKnobs,
|
|
71
74
|
resolveScope,
|
|
@@ -85,7 +88,7 @@ import {
|
|
|
85
88
|
validateRelayWsUrl,
|
|
86
89
|
withAgentGitEnv,
|
|
87
90
|
wsToHttp
|
|
88
|
-
} from "./chunk-
|
|
91
|
+
} from "./chunk-3SHZYLWH.js";
|
|
89
92
|
import {
|
|
90
93
|
clampUntrusted,
|
|
91
94
|
externalSourceLabel,
|
|
@@ -413,6 +416,10 @@ var ChannelSession = class {
|
|
|
413
416
|
setHumans(entries) {
|
|
414
417
|
this.humans = entries;
|
|
415
418
|
}
|
|
419
|
+
/** D15 (git gateway): called with true before a lisa fresh turn and false after it
|
|
420
|
+
* (in a finally, so a failed turn still re-enables). The bridge wires it to the
|
|
421
|
+
* channel broker's setGitEnabled so critics and builder rounds cannot use git. */
|
|
422
|
+
onLisaFreshTurn;
|
|
416
423
|
/**
|
|
417
424
|
* Handoff hold (persist-and-catch-up): non-null while the owner is driving the real
|
|
418
425
|
* interactive session. Live messages land HERE instead of the (intentionally stopped)
|
|
@@ -592,6 +599,14 @@ ${fence("reactions", formatReactions(msg.reactions))}
|
|
|
592
599
|
* session or fabricates freshness/usage.
|
|
593
600
|
*/
|
|
594
601
|
async runLisaFreshTurn(signal) {
|
|
602
|
+
this.onLisaFreshTurn?.(true);
|
|
603
|
+
try {
|
|
604
|
+
await this.runLisaFreshTurnInner(signal);
|
|
605
|
+
} finally {
|
|
606
|
+
this.onLisaFreshTurn?.(false);
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
async runLisaFreshTurnInner(signal) {
|
|
595
610
|
if (!this.flow) {
|
|
596
611
|
throw new Error(
|
|
597
612
|
"[braid] lisa fresh-session turn requested but flow routing is unavailable for this session"
|
|
@@ -906,11 +921,11 @@ async function elicitCard(converse, agentName, opts = {}) {
|
|
|
906
921
|
return buildPartialCard(agentName, { description: opts.fallbackDescription ?? "", skills: [] });
|
|
907
922
|
}
|
|
908
923
|
function withTimeout(p, ms) {
|
|
909
|
-
return new Promise((
|
|
924
|
+
return new Promise((resolve2, reject) => {
|
|
910
925
|
const t = setTimeout(() => reject(new Error("elicit timeout")), ms);
|
|
911
926
|
p.then((v) => {
|
|
912
927
|
clearTimeout(t);
|
|
913
|
-
|
|
928
|
+
resolve2(v);
|
|
914
929
|
}, (e) => {
|
|
915
930
|
clearTimeout(t);
|
|
916
931
|
reject(e);
|
|
@@ -1586,7 +1601,7 @@ function startControlSocket(opts) {
|
|
|
1586
1601
|
handleConnection(conn, opts, token, log2);
|
|
1587
1602
|
});
|
|
1588
1603
|
server.on("error", (err) => log2(`[braid] control socket server error: ${err.message}`));
|
|
1589
|
-
return new Promise((
|
|
1604
|
+
return new Promise((resolve2, reject) => {
|
|
1590
1605
|
server.once("error", reject);
|
|
1591
1606
|
server.listen(opts.sockPath, () => {
|
|
1592
1607
|
server.removeListener("error", reject);
|
|
@@ -1597,7 +1612,7 @@ function startControlSocket(opts) {
|
|
|
1597
1612
|
}
|
|
1598
1613
|
}
|
|
1599
1614
|
log2(`[braid] control socket listening at ${opts.sockPath}`);
|
|
1600
|
-
|
|
1615
|
+
resolve2({
|
|
1601
1616
|
path: opts.sockPath,
|
|
1602
1617
|
close: () => new Promise((res) => {
|
|
1603
1618
|
for (const c of conns) {
|
|
@@ -1628,7 +1643,7 @@ function startControlSocket(opts) {
|
|
|
1628
1643
|
});
|
|
1629
1644
|
}
|
|
1630
1645
|
function requestStatus(sockPath, timeoutMs = 3e3, token) {
|
|
1631
|
-
return new Promise((
|
|
1646
|
+
return new Promise((resolve2, reject) => {
|
|
1632
1647
|
const c = net.createConnection(sockPath);
|
|
1633
1648
|
let buf = "";
|
|
1634
1649
|
let settled = false;
|
|
@@ -1662,7 +1677,7 @@ function requestStatus(sockPath, timeoutMs = 3e3, token) {
|
|
|
1662
1677
|
finish(() => reject(new Error(`control socket status error: ${String(obj.error)}`)));
|
|
1663
1678
|
return;
|
|
1664
1679
|
}
|
|
1665
|
-
finish(() =>
|
|
1680
|
+
finish(() => resolve2(obj));
|
|
1666
1681
|
});
|
|
1667
1682
|
c.on("error", (e) => finish(() => reject(e)));
|
|
1668
1683
|
c.on("close", () => finish(() => reject(new Error("control socket closed before a status line"))));
|
|
@@ -1680,7 +1695,7 @@ function socketLive(sockPath, platform = process.platform, timeoutMs = 1e3) {
|
|
|
1680
1695
|
if (platform !== "win32") {
|
|
1681
1696
|
return Promise.resolve(existsSync2(sockPath));
|
|
1682
1697
|
}
|
|
1683
|
-
return new Promise((
|
|
1698
|
+
return new Promise((resolve2) => {
|
|
1684
1699
|
let settled = false;
|
|
1685
1700
|
const done = (live) => {
|
|
1686
1701
|
if (settled) return;
|
|
@@ -1690,7 +1705,7 @@ function socketLive(sockPath, platform = process.platform, timeoutMs = 1e3) {
|
|
|
1690
1705
|
c.destroy();
|
|
1691
1706
|
} catch {
|
|
1692
1707
|
}
|
|
1693
|
-
|
|
1708
|
+
resolve2(live);
|
|
1694
1709
|
};
|
|
1695
1710
|
const c = net.createConnection(sockPath);
|
|
1696
1711
|
const timer = setTimeout(() => done(false), timeoutMs);
|
|
@@ -1842,7 +1857,7 @@ function startAttachStream(conn, hub, channelId) {
|
|
|
1842
1857
|
function startHandoffConversation(conn, ctl, initialChannelId, writeLine, log2) {
|
|
1843
1858
|
let phase = "selecting";
|
|
1844
1859
|
let channelId = null;
|
|
1845
|
-
const
|
|
1860
|
+
const fail2 = (error) => {
|
|
1846
1861
|
phase = "closed";
|
|
1847
1862
|
writeLine({ error });
|
|
1848
1863
|
conn.end();
|
|
@@ -1850,7 +1865,7 @@ function startHandoffConversation(conn, ctl, initialChannelId, writeLine, log2)
|
|
|
1850
1865
|
const doOffer = (id) => {
|
|
1851
1866
|
const res = ctl.offer(id);
|
|
1852
1867
|
if (!res.ok) {
|
|
1853
|
-
|
|
1868
|
+
fail2(res.error);
|
|
1854
1869
|
return;
|
|
1855
1870
|
}
|
|
1856
1871
|
channelId = id;
|
|
@@ -1889,14 +1904,14 @@ function startHandoffConversation(conn, ctl, initialChannelId, writeLine, log2)
|
|
|
1889
1904
|
return;
|
|
1890
1905
|
}
|
|
1891
1906
|
if (!res.ok) {
|
|
1892
|
-
|
|
1907
|
+
fail2(res.error);
|
|
1893
1908
|
return;
|
|
1894
1909
|
}
|
|
1895
1910
|
phase = "spec_sent";
|
|
1896
1911
|
writeLine({ handoff: "spec", ...res.spec });
|
|
1897
1912
|
}).catch((e) => {
|
|
1898
1913
|
log2(`[braid] handoff begin failed: ${e.message}`);
|
|
1899
|
-
if (phase !== "closed")
|
|
1914
|
+
if (phase !== "closed") fail2("begin_failed");
|
|
1900
1915
|
});
|
|
1901
1916
|
return;
|
|
1902
1917
|
}
|
|
@@ -1915,7 +1930,7 @@ function startHandoffConversation(conn, ctl, initialChannelId, writeLine, log2)
|
|
|
1915
1930
|
});
|
|
1916
1931
|
return;
|
|
1917
1932
|
}
|
|
1918
|
-
|
|
1933
|
+
fail2("protocol_error");
|
|
1919
1934
|
};
|
|
1920
1935
|
}
|
|
1921
1936
|
function startRecoverConversation(conn, ctl, writeLine, log2) {
|
|
@@ -2448,6 +2463,982 @@ function makeSocketHandoffController(deps) {
|
|
|
2448
2463
|
};
|
|
2449
2464
|
}
|
|
2450
2465
|
|
|
2466
|
+
// src/gitGateway/gateway.ts
|
|
2467
|
+
import { chmodSync as chmodSync4, existsSync as existsSync6, lstatSync as lstatSync4, mkdirSync as mkdirSync5, readFileSync as readFileSync7, unlinkSync as unlinkSync3, writeFileSync as writeFileSync5 } from "fs";
|
|
2468
|
+
import { dirname as dirname4, join as join7 } from "path";
|
|
2469
|
+
|
|
2470
|
+
// src/gitGateway/git.ts
|
|
2471
|
+
import { execFile } from "child_process";
|
|
2472
|
+
import { existsSync as existsSync5, lstatSync as lstatSync3, readFileSync as readFileSync6, realpathSync, statSync as statSync2 } from "fs";
|
|
2473
|
+
import { delimiter, isAbsolute, join as join6, resolve, sep as sep2 } from "path";
|
|
2474
|
+
var HARDENED_CONFIG = [
|
|
2475
|
+
["core.hooksPath", "/dev/null"],
|
|
2476
|
+
["core.fsmonitor", "false"],
|
|
2477
|
+
["core.alternateRefsCommand", ""],
|
|
2478
|
+
["uploadpack.packObjectsHook", ""],
|
|
2479
|
+
["protocol.ext.allow", "never"],
|
|
2480
|
+
["protocol.file.allow", "user"],
|
|
2481
|
+
["protocol.allow", "user"],
|
|
2482
|
+
["credential.helper", ""],
|
|
2483
|
+
["core.askPass", ""],
|
|
2484
|
+
["core.sshCommand", "/usr/bin/false"],
|
|
2485
|
+
["safe.directory", "*"]
|
|
2486
|
+
];
|
|
2487
|
+
var FORBIDDEN_EXTRA = /* @__PURE__ */ new Set(["GIT_EXEC_PATH", "GIT_PROXY_COMMAND", "GIT_DIR", "GIT_WORK_TREE", "GIT_EXTERNAL_DIFF", "GIT_SSH"]);
|
|
2488
|
+
function hardenedGitEnv(extra = {}) {
|
|
2489
|
+
const safeExtra = {};
|
|
2490
|
+
for (const [k, v] of Object.entries(extra)) {
|
|
2491
|
+
if (k.startsWith("GIT_CONFIG") || FORBIDDEN_EXTRA.has(k)) continue;
|
|
2492
|
+
safeExtra[k] = v;
|
|
2493
|
+
}
|
|
2494
|
+
const env = {
|
|
2495
|
+
...safeExtra,
|
|
2496
|
+
PATH: safeExtra.PATH ?? process.env.PATH ?? "/usr/bin:/bin",
|
|
2497
|
+
HOME: safeExtra.HOME ?? process.env.HOME ?? "/",
|
|
2498
|
+
GIT_CONFIG_NOSYSTEM: "1",
|
|
2499
|
+
GIT_CONFIG_GLOBAL: "/dev/null",
|
|
2500
|
+
GIT_TERMINAL_PROMPT: "0",
|
|
2501
|
+
GIT_ASKPASS: "",
|
|
2502
|
+
GCM_INTERACTIVE: "never",
|
|
2503
|
+
GIT_SSH_COMMAND: "/usr/bin/false",
|
|
2504
|
+
GIT_PAGER: "cat",
|
|
2505
|
+
LC_ALL: "C"
|
|
2506
|
+
};
|
|
2507
|
+
HARDENED_CONFIG.forEach(([k, v], i) => {
|
|
2508
|
+
env[`GIT_CONFIG_KEY_${i}`] = k;
|
|
2509
|
+
env[`GIT_CONFIG_VALUE_${i}`] = v;
|
|
2510
|
+
});
|
|
2511
|
+
env.GIT_CONFIG_COUNT = String(HARDENED_CONFIG.length);
|
|
2512
|
+
return env;
|
|
2513
|
+
}
|
|
2514
|
+
var gitBinary = null;
|
|
2515
|
+
function resolveGitBinary() {
|
|
2516
|
+
if (gitBinary) return gitBinary;
|
|
2517
|
+
const candidates = [
|
|
2518
|
+
...(process.env.PATH ?? "").split(delimiter).filter(Boolean).filter((d) => isAbsolute(d)).map((d) => join6(d, "git")),
|
|
2519
|
+
"/usr/bin/git",
|
|
2520
|
+
"/usr/local/bin/git",
|
|
2521
|
+
"/opt/homebrew/bin/git"
|
|
2522
|
+
];
|
|
2523
|
+
for (const c of candidates) {
|
|
2524
|
+
try {
|
|
2525
|
+
if (statSync2(c).isFile()) {
|
|
2526
|
+
gitBinary = c;
|
|
2527
|
+
return c;
|
|
2528
|
+
}
|
|
2529
|
+
} catch {
|
|
2530
|
+
}
|
|
2531
|
+
}
|
|
2532
|
+
throw new Error("[git-gateway] git binary not found on PATH or in the usual locations");
|
|
2533
|
+
}
|
|
2534
|
+
var MAX_OUTPUT = 1024 * 1024;
|
|
2535
|
+
async function runGit(args, opts = {}) {
|
|
2536
|
+
for (const a of args) {
|
|
2537
|
+
if (typeof a !== "string") throw new Error("[git-gateway] non-string argv");
|
|
2538
|
+
}
|
|
2539
|
+
const argv = opts.gitDir ? ["--git-dir", opts.gitDir, ...args] : args;
|
|
2540
|
+
return new Promise((resolvePromise, reject) => {
|
|
2541
|
+
const child = execFile(
|
|
2542
|
+
resolveGitBinary(),
|
|
2543
|
+
argv,
|
|
2544
|
+
{ cwd: opts.cwd, env: hardenedGitEnv(opts.extraEnv), timeout: opts.timeoutMs ?? 12e4, maxBuffer: MAX_OUTPUT, windowsHide: true, killSignal: "SIGKILL" },
|
|
2545
|
+
(err, stdout, stderr) => {
|
|
2546
|
+
if (err && err.killed) {
|
|
2547
|
+
return reject(new Error(`[git-gateway] git ${args[0] ?? ""} timed out after ${opts.timeoutMs ?? 12e4} ms`));
|
|
2548
|
+
}
|
|
2549
|
+
if (err && typeof err.code !== "number") return reject(err);
|
|
2550
|
+
resolvePromise({ code: err ? Number(err.code) : 0, stdout: String(stdout), stderr: String(stderr) });
|
|
2551
|
+
}
|
|
2552
|
+
);
|
|
2553
|
+
child.stdin?.on("error", () => {
|
|
2554
|
+
});
|
|
2555
|
+
if (opts.input !== void 0) child.stdin?.end(opts.input);
|
|
2556
|
+
else child.stdin?.end();
|
|
2557
|
+
});
|
|
2558
|
+
}
|
|
2559
|
+
function isInside(child, parent) {
|
|
2560
|
+
return child === parent || child.startsWith(parent.endsWith(sep2) ? parent : parent + sep2);
|
|
2561
|
+
}
|
|
2562
|
+
function validateWorktree(path2, writableRoots) {
|
|
2563
|
+
let real;
|
|
2564
|
+
try {
|
|
2565
|
+
real = realpathSync(resolve(path2));
|
|
2566
|
+
} catch {
|
|
2567
|
+
return { ok: false, reason: `path does not exist: ${path2}` };
|
|
2568
|
+
}
|
|
2569
|
+
const roots = writableRoots.map((r) => {
|
|
2570
|
+
try {
|
|
2571
|
+
return realpathSync(r);
|
|
2572
|
+
} catch {
|
|
2573
|
+
return resolve(r);
|
|
2574
|
+
}
|
|
2575
|
+
});
|
|
2576
|
+
if (!roots.some((r) => isInside(real, r))) return { ok: false, reason: `path is outside the sandbox's writable set: ${path2}` };
|
|
2577
|
+
const gitDir = join6(real, ".git");
|
|
2578
|
+
let st;
|
|
2579
|
+
try {
|
|
2580
|
+
st = lstatSync3(gitDir);
|
|
2581
|
+
} catch {
|
|
2582
|
+
return { ok: false, reason: `not a git repository (no .git): ${path2}` };
|
|
2583
|
+
}
|
|
2584
|
+
if (!st.isDirectory()) return { ok: false, reason: `.git is a gitfile or symlink, refused: ${path2}` };
|
|
2585
|
+
if (existsSync5(join6(gitDir, "objects", "info", "alternates"))) return { ok: false, reason: ".git/objects/info/alternates present, refused" };
|
|
2586
|
+
let cfg = "";
|
|
2587
|
+
try {
|
|
2588
|
+
cfg = readFileSync6(join6(gitDir, "config"), "utf8");
|
|
2589
|
+
} catch (e) {
|
|
2590
|
+
if (e.code !== "ENOENT") return { ok: false, reason: ".git/config could not be read, refused" };
|
|
2591
|
+
}
|
|
2592
|
+
if (/^\s*\[include(If)?\b/im.test(cfg)) return { ok: false, reason: ".git/config contains an include section, refused" };
|
|
2593
|
+
return { ok: true, gitDir };
|
|
2594
|
+
}
|
|
2595
|
+
|
|
2596
|
+
// src/gitGateway/gateway.ts
|
|
2597
|
+
var BRANCH_RE = /^(?!-)(?!refs\/)(?!.*\.\.)(?!.*\.lock$)(?!HEAD$)[A-Za-z0-9._\/-]{1,200}$/;
|
|
2598
|
+
var RISK_PATHS = [/^\.gitmodules$/, /^\.gitattributes$/, /^package\.json$/, /^package-lock\.json$/, /^pnpm-lock\.yaml$/, /^yarn\.lock$/, /^Makefile$/, /^[^/]+\.sh$/, /^\.git[^/]*$/];
|
|
2599
|
+
var WORKFLOW_PATH_RE = /^\.github\/workflows\//;
|
|
2600
|
+
var GITHUB_API = "https://api.github.com";
|
|
2601
|
+
var MERGE_METHODS = /* @__PURE__ */ new Set(["merge", "squash", "rebase"]);
|
|
2602
|
+
var LOCAL_TIMEOUT_MS = 12e4;
|
|
2603
|
+
var NETWORK_TIMEOUT_MS = 3e5;
|
|
2604
|
+
function fail(status, code, text) {
|
|
2605
|
+
return { ok: false, status, code, text };
|
|
2606
|
+
}
|
|
2607
|
+
function scrub(s) {
|
|
2608
|
+
return s.replace(/gh[pousr]_[A-Za-z0-9_]+|github_pat_[A-Za-z0-9_]+|x-access-token:[^@\s]+/g, "[redacted]");
|
|
2609
|
+
}
|
|
2610
|
+
function oneLine(s, max = 300) {
|
|
2611
|
+
return scrub(s).replace(/\s+/g, " ").trim().slice(0, max);
|
|
2612
|
+
}
|
|
2613
|
+
function errText(e) {
|
|
2614
|
+
return oneLine(e instanceof Error ? e.message : String(e));
|
|
2615
|
+
}
|
|
2616
|
+
var GH_OWNER_RE = /^[A-Za-z0-9-]{1,100}$/;
|
|
2617
|
+
var GH_REPO_RE = /^(?!\.\.?$)[A-Za-z0-9._-]{1,100}$/;
|
|
2618
|
+
function parseGitHubOrigin(url) {
|
|
2619
|
+
const s = url.trim();
|
|
2620
|
+
const m = /^https:\/\/(?:[^@/\s]+@)?github\.com\/(.+)$/i.exec(s) ?? /^ssh:\/\/(?:[^@/\s]+@)?github\.com(?::22)?\/(.+)$/i.exec(s) ?? /^(?:[^@/\s:]+@)?github\.com:(?!\/)(.+)$/i.exec(s);
|
|
2621
|
+
if (!m) return null;
|
|
2622
|
+
const parts = m[1].replace(/\/$/, "").replace(/\.git$/i, "").split("/");
|
|
2623
|
+
if (parts.length !== 2) return null;
|
|
2624
|
+
const [owner, name] = parts;
|
|
2625
|
+
return GH_OWNER_RE.test(owner) && GH_REPO_RE.test(name) ? { owner, name } : null;
|
|
2626
|
+
}
|
|
2627
|
+
function shellQuote(p) {
|
|
2628
|
+
return `'${p.replace(/'/g, `'\\''`)}'`;
|
|
2629
|
+
}
|
|
2630
|
+
function credentialHelperArgs(credFile) {
|
|
2631
|
+
return ["-c", "credential.helper=", "-c", `credential.helper=store --file=${shellQuote(credFile)}`];
|
|
2632
|
+
}
|
|
2633
|
+
function safeSegment(s) {
|
|
2634
|
+
return s.replace(/[^a-zA-Z0-9._-]/g, "_");
|
|
2635
|
+
}
|
|
2636
|
+
function logSafe(s) {
|
|
2637
|
+
return s.replace(/[^A-Za-z0-9._\/-]/g, "?").slice(0, 200) || "-";
|
|
2638
|
+
}
|
|
2639
|
+
function repoKey(g) {
|
|
2640
|
+
return `${g.repoOwner}/${g.repoName}`.toLowerCase();
|
|
2641
|
+
}
|
|
2642
|
+
function isAutonomous(g) {
|
|
2643
|
+
return g.mode === "autonomous";
|
|
2644
|
+
}
|
|
2645
|
+
var CONFIG_INCLUDE_RE = /^\s*\[include(If)?\b/im;
|
|
2646
|
+
function readConfig(gitDir) {
|
|
2647
|
+
try {
|
|
2648
|
+
return readFileSync7(join7(gitDir, "config"), "utf8");
|
|
2649
|
+
} catch (e) {
|
|
2650
|
+
if (e.code === "ENOENT") return "";
|
|
2651
|
+
throw e;
|
|
2652
|
+
}
|
|
2653
|
+
}
|
|
2654
|
+
var GitGateway = class {
|
|
2655
|
+
constructor(deps) {
|
|
2656
|
+
this.deps = deps;
|
|
2657
|
+
this.fetchFn = deps.fetchFn ?? ((...a) => fetch(...a));
|
|
2658
|
+
}
|
|
2659
|
+
deps;
|
|
2660
|
+
/** D15: per-channel disable depth; git is enabled only at depth 0 (overlapping lisa turns nest). */
|
|
2661
|
+
disabledDepth = /* @__PURE__ */ new Map();
|
|
2662
|
+
locks = /* @__PURE__ */ new Map();
|
|
2663
|
+
knownDefault = /* @__PURE__ */ new Map();
|
|
2664
|
+
fetchFn;
|
|
2665
|
+
/** D15: lisa fresh turns (and the critics they spawn) must not use the gateway. */
|
|
2666
|
+
setChannelGitEnabled(channelId, enabled) {
|
|
2667
|
+
const depth = Math.max(0, (this.disabledDepth.get(channelId) ?? 0) + (enabled ? -1 : 1));
|
|
2668
|
+
if (depth === 0) this.disabledDepth.delete(channelId);
|
|
2669
|
+
else this.disabledDepth.set(channelId, depth);
|
|
2670
|
+
}
|
|
2671
|
+
status(channelId, args) {
|
|
2672
|
+
return this.guard("status", () => this.statusInner(channelId, args));
|
|
2673
|
+
}
|
|
2674
|
+
fetch(channelId, args) {
|
|
2675
|
+
return this.guard("fetch", () => this.fetchInner(channelId, args));
|
|
2676
|
+
}
|
|
2677
|
+
async push(channelId, args) {
|
|
2678
|
+
const branch = String(args.branch ?? "");
|
|
2679
|
+
const out = {};
|
|
2680
|
+
const r = await this.guard("push", () => this.pushInner(channelId, branch, args, out));
|
|
2681
|
+
this.log(`push ${out.repo ?? "-"} ${logSafe(branch)} ${out.sha ?? "-"} ${r.ok ? "ok" : `refused:${r.code}`}`);
|
|
2682
|
+
return r;
|
|
2683
|
+
}
|
|
2684
|
+
openPr(channelId, args) {
|
|
2685
|
+
return this.guard("pull request", () => this.openPrInner(channelId, args));
|
|
2686
|
+
}
|
|
2687
|
+
mergePr(channelId, args) {
|
|
2688
|
+
return this.guard("merge", () => this.mergePrInner(channelId, args));
|
|
2689
|
+
}
|
|
2690
|
+
// ---- operations ----
|
|
2691
|
+
async statusInner(channelId, args) {
|
|
2692
|
+
const gate = this.gateChecks(channelId);
|
|
2693
|
+
if (gate) return gate;
|
|
2694
|
+
const grant = await this.resolveGrant(channelId, args.path, true);
|
|
2695
|
+
if (!grant.ok) return grant;
|
|
2696
|
+
const g = grant.grant;
|
|
2697
|
+
const wt = this.worktree(channelId, args.path);
|
|
2698
|
+
if (!wt.ok) return wt;
|
|
2699
|
+
const opts = { gitDir: wt.gitDir, cwd: wt.path };
|
|
2700
|
+
const br = await this.git(["rev-parse", "--abbrev-ref", "HEAD"], opts);
|
|
2701
|
+
const hd = await this.git(["rev-parse", "--verify", "-q", "HEAD"], opts);
|
|
2702
|
+
const branch = br.code === 0 ? br.stdout.trim() : null;
|
|
2703
|
+
const head = hd.code === 0 ? hd.stdout.trim() : null;
|
|
2704
|
+
let ahead = null;
|
|
2705
|
+
let behind = null;
|
|
2706
|
+
let tracking;
|
|
2707
|
+
if (!head || !branch || branch === "HEAD") {
|
|
2708
|
+
tracking = head ? "HEAD is detached, so there is no braid/<branch> to compare with." : "The worktree has no commits yet.";
|
|
2709
|
+
} else {
|
|
2710
|
+
const ref = `refs/remotes/braid/${branch}`;
|
|
2711
|
+
const has = await this.git(["rev-parse", "--verify", "-q", ref], opts);
|
|
2712
|
+
if (has.code !== 0) {
|
|
2713
|
+
tracking = `There is no fetched copy of braid/${branch}; run braid_git_fetch to compare with the remote.`;
|
|
2714
|
+
} else {
|
|
2715
|
+
const c = await this.git(["rev-list", "--left-right", "--count", `HEAD...${ref}`], opts);
|
|
2716
|
+
const m = /^(\d+)\s+(\d+)/.exec(c.stdout.trim());
|
|
2717
|
+
if (c.code === 0 && m) {
|
|
2718
|
+
ahead = Number(m[1]);
|
|
2719
|
+
behind = Number(m[2]);
|
|
2720
|
+
tracking = `Ahead ${ahead}, behind ${behind} of braid/${branch} (as of the last braid_git_fetch).`;
|
|
2721
|
+
} else {
|
|
2722
|
+
tracking = `Could not compare with braid/${branch}; run braid_git_fetch and try again.`;
|
|
2723
|
+
}
|
|
2724
|
+
}
|
|
2725
|
+
}
|
|
2726
|
+
const repo = `${g.repoOwner}/${g.repoName}`;
|
|
2727
|
+
const mode = isAutonomous(g) ? "autonomous" : "pr-only";
|
|
2728
|
+
const lines = [
|
|
2729
|
+
`Git access: ${repo}, mode ${mode}.`,
|
|
2730
|
+
`Rules: ${this.rulesText(g)}`,
|
|
2731
|
+
`Worktree ${wt.path}: branch ${branch ?? "unknown"}, HEAD ${head ?? "none"}.`,
|
|
2732
|
+
tracking
|
|
2733
|
+
];
|
|
2734
|
+
return { ok: true, text: lines.join("\n"), data: { repo, mode, source: g.source, branch, head, ahead, behind } };
|
|
2735
|
+
}
|
|
2736
|
+
async fetchInner(channelId, args) {
|
|
2737
|
+
const gate = this.gateChecks(channelId);
|
|
2738
|
+
if (gate) return gate;
|
|
2739
|
+
const grant = await this.resolveGrant(channelId, args.path, true);
|
|
2740
|
+
if (!grant.ok) return grant;
|
|
2741
|
+
const g = grant.grant;
|
|
2742
|
+
const mirror = this.mirrorPath(g);
|
|
2743
|
+
return this.serialize(mirror, async () => {
|
|
2744
|
+
const gate2 = this.gateChecks(channelId);
|
|
2745
|
+
if (gate2) return gate2;
|
|
2746
|
+
const m = await this.ensureMirror(mirror);
|
|
2747
|
+
if (m) return m;
|
|
2748
|
+
const cred = await this.credentialAndPolicy(g);
|
|
2749
|
+
if (!cred.ok) return cred;
|
|
2750
|
+
const wt = this.worktree(channelId, args.path);
|
|
2751
|
+
if (!wt.ok) return wt;
|
|
2752
|
+
const id = this.identity(wt);
|
|
2753
|
+
if (!id) return this.worktreeChanged("fetch");
|
|
2754
|
+
const remoteUrl = this.remoteUrl(g);
|
|
2755
|
+
const credFile = join7(mirror, ".braid-cred");
|
|
2756
|
+
try {
|
|
2757
|
+
this.writeCredFile(credFile, cred.value.token);
|
|
2758
|
+
const r = await this.git([...credentialHelperArgs(credFile), "fetch", "--quiet", "--no-tags", "--prune", remoteUrl, "+refs/heads/*:refs/remotes/braid/*"], { gitDir: mirror }, true);
|
|
2759
|
+
if (r.code !== 0) return this.remoteRejected(g, r.stderr);
|
|
2760
|
+
} finally {
|
|
2761
|
+
this.removeCredFile(credFile);
|
|
2762
|
+
}
|
|
2763
|
+
if (!this.sameIdentity(wt, id)) return this.worktreeChanged("fetch");
|
|
2764
|
+
const into = await this.git(["fetch", "--quiet", "--no-tags", "--prune", mirror, "+refs/remotes/braid/*:refs/remotes/braid/*"], { gitDir: wt.gitDir, cwd: wt.path }, true);
|
|
2765
|
+
if (into.code !== 0) return fail(502, "fetch_into_worktree_failed", `Fetched ${g.repoOwner}/${g.repoName}, but could not update the worktree's braid refs: ${oneLine(into.stderr)}.`);
|
|
2766
|
+
const list = await this.git(["for-each-ref", "--format=%(refname:short) %(objectname:short=12)", "refs/remotes/braid/"], { gitDir: wt.gitDir, cwd: wt.path });
|
|
2767
|
+
const refs = list.stdout.split("\n").filter(Boolean);
|
|
2768
|
+
const shown = refs.slice(0, 30).join("\n");
|
|
2769
|
+
const more = refs.length > 30 ? `
|
|
2770
|
+
(${refs.length - 30} more)` : "";
|
|
2771
|
+
this.log(`fetch ${g.repoOwner}/${g.repoName} ok`);
|
|
2772
|
+
return {
|
|
2773
|
+
ok: true,
|
|
2774
|
+
text: `Fetched ${g.repoOwner}/${g.repoName} into the worktree as remote braid (${refs.length} branches). Rebase onto braid/<branch> before pushing if the remote moved.
|
|
2775
|
+
${shown}${more}`,
|
|
2776
|
+
data: { refs: refs.length }
|
|
2777
|
+
};
|
|
2778
|
+
});
|
|
2779
|
+
}
|
|
2780
|
+
/** D8, step by step. */
|
|
2781
|
+
async pushInner(channelId, branch, args, out) {
|
|
2782
|
+
const gate = this.gateChecks(channelId);
|
|
2783
|
+
if (gate) return gate;
|
|
2784
|
+
const grant = await this.resolveGrant(channelId, args.path, true);
|
|
2785
|
+
if (!grant.ok) return grant;
|
|
2786
|
+
const listed = grant.grant;
|
|
2787
|
+
const repo = `${listed.repoOwner}/${listed.repoName}`;
|
|
2788
|
+
out.repo = repo;
|
|
2789
|
+
const bad = await this.checkBranch(branch);
|
|
2790
|
+
if (bad) return bad;
|
|
2791
|
+
const ns = this.namespace();
|
|
2792
|
+
if (!isAutonomous(listed) && !branch.startsWith(ns)) {
|
|
2793
|
+
const hint = this.knownDefault.get(repo) ?? await this.worktreeDefaultHint(channelId, args.path);
|
|
2794
|
+
return hint === branch ? this.defaultProtected(branch) : this.outsideNamespace();
|
|
2795
|
+
}
|
|
2796
|
+
const mirror = this.mirrorPath(listed);
|
|
2797
|
+
return this.serialize(mirror, async () => {
|
|
2798
|
+
const gate2 = this.gateChecks(channelId);
|
|
2799
|
+
if (gate2) return gate2;
|
|
2800
|
+
const m = await this.ensureMirror(mirror);
|
|
2801
|
+
if (m) return m;
|
|
2802
|
+
const cred = await this.credentialAndPolicy(listed);
|
|
2803
|
+
if (!cred.ok) return cred;
|
|
2804
|
+
const g = cred.grant;
|
|
2805
|
+
const meta = await this.repoMeta(g, cred.value);
|
|
2806
|
+
if (!meta.ok) return meta;
|
|
2807
|
+
const pub = this.publicCheck(g, meta.value);
|
|
2808
|
+
if (pub) return pub;
|
|
2809
|
+
if (!isAutonomous(g)) {
|
|
2810
|
+
if (meta.value.defaultBranch === branch) return this.defaultProtected(branch);
|
|
2811
|
+
if (!branch.startsWith(ns)) return this.outsideNamespace();
|
|
2812
|
+
}
|
|
2813
|
+
const wt = this.worktree(channelId, args.path);
|
|
2814
|
+
if (!wt.ok) return wt;
|
|
2815
|
+
const id = this.identity(wt);
|
|
2816
|
+
if (!id) return this.worktreeChanged("push", mirror);
|
|
2817
|
+
const sha = await this.resolveSha(wt, args.sha ?? "HEAD");
|
|
2818
|
+
if (!sha.ok) return sha;
|
|
2819
|
+
out.sha = sha.sha;
|
|
2820
|
+
if (!this.sameIdentity(wt, id)) return this.worktreeChanged("push", mirror);
|
|
2821
|
+
const inc = await this.git(["fetch", "--quiet", "--no-tags", wt.gitDir, `+${sha.sha}:refs/braid/incoming`], { gitDir: mirror }, true);
|
|
2822
|
+
if (!this.sameIdentity(wt, id)) return this.worktreeChanged("push", mirror);
|
|
2823
|
+
if (inc.code !== 0) return fail(502, "fetch_from_worktree_failed", `Could not read commit ${sha.sha.slice(0, 12)} from the worktree: ${oneLine(inc.stderr)}.`);
|
|
2824
|
+
const remoteUrl = this.remoteUrl(g);
|
|
2825
|
+
const credFile = join7(mirror, ".braid-cred");
|
|
2826
|
+
try {
|
|
2827
|
+
this.writeCredFile(credFile, cred.value.token);
|
|
2828
|
+
const probe = await this.probeRemote(g, mirror, remoteUrl, credFile, branch, meta.value.defaultBranch);
|
|
2829
|
+
if (!probe.ok) return probe;
|
|
2830
|
+
if (!isAutonomous(g) && probe.value.defaultBranch === branch) return this.defaultProtected(branch);
|
|
2831
|
+
if (probe.value.branchExists) {
|
|
2832
|
+
const ff = await this.fastForwardCheck(mirror, branch);
|
|
2833
|
+
if (ff) return ff;
|
|
2834
|
+
}
|
|
2835
|
+
const policy = await this.policyDiff(g, mirror, probe.value.branchExists);
|
|
2836
|
+
if (policy) return policy;
|
|
2837
|
+
if (!this.sameIdentity(wt, id)) return this.worktreeChanged("push", mirror);
|
|
2838
|
+
const pushed = await this.git([...credentialHelperArgs(credFile), "push", "--quiet", "--no-verify", remoteUrl, `refs/braid/incoming:refs/heads/${branch}`], { gitDir: mirror }, true);
|
|
2839
|
+
if (pushed.code !== 0) {
|
|
2840
|
+
if (/\b(401|403)\b|Authentication failed|could not read Username/i.test(pushed.stderr)) this.deps.resolver.dropCredential(listed);
|
|
2841
|
+
return fail(502, "push_failed", `GitHub rejected the push: ${oneLine(pushed.stderr)}. Run braid_git_fetch and braid_git_status, then try again.`);
|
|
2842
|
+
}
|
|
2843
|
+
return {
|
|
2844
|
+
ok: true,
|
|
2845
|
+
text: `Pushed ${sha.sha.slice(0, 12)} to ${repo} branch ${branch}.
|
|
2846
|
+
Compare: https://github.com/${repo}/compare/${encodeURIComponent(branch)}`,
|
|
2847
|
+
data: { branch, sha: sha.sha }
|
|
2848
|
+
};
|
|
2849
|
+
} finally {
|
|
2850
|
+
this.removeCredFile(credFile);
|
|
2851
|
+
}
|
|
2852
|
+
});
|
|
2853
|
+
}
|
|
2854
|
+
async openPrInner(channelId, args) {
|
|
2855
|
+
const gate = this.gateChecks(channelId);
|
|
2856
|
+
if (gate) return gate;
|
|
2857
|
+
const grant = await this.resolveGrant(channelId, args.path, false);
|
|
2858
|
+
if (!grant.ok) return grant;
|
|
2859
|
+
const branch = String(args.branch ?? "");
|
|
2860
|
+
const badHead = await this.checkBranch(branch);
|
|
2861
|
+
if (badHead) return badHead;
|
|
2862
|
+
const title = String(args.title ?? "").trim();
|
|
2863
|
+
if (!title) return fail(400, "invalid_title", "A pull request needs a non-empty title.");
|
|
2864
|
+
if (args.base !== void 0) {
|
|
2865
|
+
const badBase = await this.checkBranch(String(args.base));
|
|
2866
|
+
if (badBase) return badBase;
|
|
2867
|
+
}
|
|
2868
|
+
const cred = await this.credentialAndPolicy(grant.grant);
|
|
2869
|
+
if (!cred.ok) return cred;
|
|
2870
|
+
const g = cred.grant;
|
|
2871
|
+
const meta = await this.repoMeta(g, cred.value);
|
|
2872
|
+
if (!meta.ok) return meta;
|
|
2873
|
+
const pub = this.publicCheck(g, meta.value);
|
|
2874
|
+
if (pub) return pub;
|
|
2875
|
+
const base = args.base ?? meta.value.defaultBranch;
|
|
2876
|
+
if (!base) return fail(502, "default_branch_unknown", "Could not learn the repository's default branch; pass base explicitly.");
|
|
2877
|
+
const draft = !isAutonomous(g);
|
|
2878
|
+
const body = { title, ...args.body !== void 0 ? { body: String(args.body) } : {}, head: branch, base, draft };
|
|
2879
|
+
const res = await this.api(grant.grant, cred.value.token, "POST", `/repos/${this.repoPath(g)}/pulls`, body);
|
|
2880
|
+
if (!res.ok) return res;
|
|
2881
|
+
const num = res.json.number;
|
|
2882
|
+
const url = res.json.html_url;
|
|
2883
|
+
this.log(`pr-open ${g.repoOwner}/${g.repoName} ${logSafe(branch)} #${String(num)} ok`);
|
|
2884
|
+
return {
|
|
2885
|
+
ok: true,
|
|
2886
|
+
text: `Opened ${draft ? "draft " : ""}pull request #${String(num)} from ${branch} into ${base}: ${String(url)}${draft ? "\nThis repo is in pr-only mode, so a human reviews and merges it." : ""}`,
|
|
2887
|
+
data: { number: num, url }
|
|
2888
|
+
};
|
|
2889
|
+
}
|
|
2890
|
+
async mergePrInner(channelId, args) {
|
|
2891
|
+
const gate = this.gateChecks(channelId);
|
|
2892
|
+
if (gate) return gate;
|
|
2893
|
+
const grant = await this.resolveGrant(channelId, void 0, false);
|
|
2894
|
+
if (!grant.ok) return grant;
|
|
2895
|
+
const num = Number(args.number);
|
|
2896
|
+
if (!Number.isInteger(num) || num <= 0) return fail(400, "invalid_number", "Pass the pull request number as a positive integer.");
|
|
2897
|
+
const method = args.method ?? "squash";
|
|
2898
|
+
if (!MERGE_METHODS.has(method)) return fail(400, "invalid_method", "The merge method must be merge, squash or rebase.");
|
|
2899
|
+
if (!isAutonomous(grant.grant)) return this.mergeRefused(num);
|
|
2900
|
+
const cred = await this.credentialAndPolicy(grant.grant);
|
|
2901
|
+
if (!cred.ok) return cred;
|
|
2902
|
+
const g = cred.grant;
|
|
2903
|
+
if (!isAutonomous(g)) return this.mergeRefused(num);
|
|
2904
|
+
const meta = await this.repoMeta(g, cred.value);
|
|
2905
|
+
if (!meta.ok) return meta;
|
|
2906
|
+
const pub = this.publicCheck(g, meta.value);
|
|
2907
|
+
if (pub) return pub;
|
|
2908
|
+
const res = await this.api(grant.grant, cred.value.token, "PUT", `/repos/${this.repoPath(g)}/pulls/${num}/merge`, { merge_method: method });
|
|
2909
|
+
if (!res.ok) return res;
|
|
2910
|
+
const sha = String(res.json.sha ?? "");
|
|
2911
|
+
this.log(`pr-merge ${g.repoOwner}/${g.repoName} #${num} ok`);
|
|
2912
|
+
return { ok: true, text: `Pull request #${num} in ${g.repoOwner}/${g.repoName} merged ${sha} (${method}).`, data: { number: num, sha } };
|
|
2913
|
+
}
|
|
2914
|
+
// ---- push pipeline steps ----
|
|
2915
|
+
/** D8.5: one ls-remote learns whether the branch exists (and proves the token works);
|
|
2916
|
+
* then the branch, or for a new branch the default branch, is fetched into the mirror. */
|
|
2917
|
+
async probeRemote(g, mirror, remoteUrl, credFile, branch, knownDefault) {
|
|
2918
|
+
const ls = await this.git([...credentialHelperArgs(credFile), "ls-remote", "--symref", remoteUrl, "HEAD", `refs/heads/${branch}`], { gitDir: mirror }, true);
|
|
2919
|
+
if (ls.code !== 0) return this.remoteRejected(g, ls.stderr);
|
|
2920
|
+
const branchExists = ls.stdout.split("\n").some((l) => l.endsWith(` refs/heads/${branch}`));
|
|
2921
|
+
const symref = /^ref: refs\/heads\/(\S+)\tHEAD$/m.exec(ls.stdout);
|
|
2922
|
+
const defaultBranch = knownDefault ?? (symref ? symref[1] : null);
|
|
2923
|
+
if (defaultBranch) this.knownDefault.set(`${g.repoOwner}/${g.repoName}`, defaultBranch);
|
|
2924
|
+
await this.git(["update-ref", "-d", "refs/braid/remote"], { gitDir: mirror });
|
|
2925
|
+
await this.git(["update-ref", "-d", "refs/braid/base"], { gitDir: mirror });
|
|
2926
|
+
const specs = [];
|
|
2927
|
+
if (branchExists) specs.push(`+refs/heads/${branch}:refs/braid/remote`);
|
|
2928
|
+
else if (defaultBranch && BRANCH_RE.test(defaultBranch)) specs.push(`+refs/heads/${defaultBranch}:refs/braid/base`);
|
|
2929
|
+
if (specs.length > 0) {
|
|
2930
|
+
const rf = await this.git([...credentialHelperArgs(credFile), "fetch", "--quiet", "--no-tags", remoteUrl, ...specs], { gitDir: mirror }, true);
|
|
2931
|
+
if (rf.code !== 0) return this.remoteRejected(g, rf.stderr);
|
|
2932
|
+
}
|
|
2933
|
+
return { ok: true, value: { branchExists, defaultBranch } };
|
|
2934
|
+
}
|
|
2935
|
+
/** D8.6: never --force; the remote tip must be an ancestor of what we push. */
|
|
2936
|
+
async fastForwardCheck(mirror, branch) {
|
|
2937
|
+
const ff = await this.git(["merge-base", "--is-ancestor", "refs/braid/remote", "refs/braid/incoming"], { gitDir: mirror });
|
|
2938
|
+
if (ff.code === 0) return null;
|
|
2939
|
+
return fail(409, "not_fast_forward", `Remote ${branch} has commits you do not have. Run braid_git_fetch, rebase onto braid/${branch}, then push again.`);
|
|
2940
|
+
}
|
|
2941
|
+
/** D8.8 (ruling R12): the changed paths, against the remote branch when it exists,
|
|
2942
|
+
* else against the merge-base with the default branch, else (orphan) the empty tree. */
|
|
2943
|
+
async policyDiff(g, mirror, branchExists) {
|
|
2944
|
+
const base = branchExists ? "refs/braid/remote" : await this.newBranchBase(mirror);
|
|
2945
|
+
if (!base) return fail(502, "diff_failed", "Could not compute what this push changes; try again.");
|
|
2946
|
+
const diff = await this.git(["diff", "--no-renames", "--name-only", "-z", base, "refs/braid/incoming", "--"], { gitDir: mirror });
|
|
2947
|
+
if (diff.code !== 0) return fail(502, "diff_failed", `Could not compute what this push changes: ${oneLine(diff.stderr)}.`);
|
|
2948
|
+
const files = diff.stdout.split("\0").filter(Boolean);
|
|
2949
|
+
if (!g.allowWorkflows && files.some((f) => WORKFLOW_PATH_RE.test(f))) return fail(403, "workflows_not_allowed", "This push changes .github/workflows and the grant does not allow workflow changes. Remove those changes, or ask the owner to enable workflow changes on the grant.");
|
|
2950
|
+
const risky = files.filter((f) => RISK_PATHS.some((re) => re.test(f)));
|
|
2951
|
+
if (!isAutonomous(g) && !g.allowRiskPaths && risky.length > 0) return fail(403, "risk_path_needs_approval", `This push touches files the owner must approve in pr-only mode (${risky.slice(0, 20).join(", ")}). Remove those changes, or ask the owner to enable "risky file changes" on the grant.`);
|
|
2952
|
+
return null;
|
|
2953
|
+
}
|
|
2954
|
+
async newBranchBase(mirror) {
|
|
2955
|
+
const has = await this.git(["rev-parse", "--verify", "-q", "refs/braid/base"], { gitDir: mirror });
|
|
2956
|
+
if (has.code === 0) {
|
|
2957
|
+
const mb = await this.git(["merge-base", "refs/braid/base", "refs/braid/incoming"], { gitDir: mirror });
|
|
2958
|
+
const sha = mb.stdout.trim();
|
|
2959
|
+
if (mb.code === 0 && sha) return sha;
|
|
2960
|
+
}
|
|
2961
|
+
const empty = await this.git(["hash-object", "-t", "tree", "--stdin"], { gitDir: mirror, input: "" });
|
|
2962
|
+
return empty.code === 0 ? empty.stdout.trim() : null;
|
|
2963
|
+
}
|
|
2964
|
+
// ---- helpers ----
|
|
2965
|
+
git(args, opts, network = false) {
|
|
2966
|
+
return runGit(args, { ...opts, timeoutMs: this.deps.gitTimeoutMs ?? (network ? NETWORK_TIMEOUT_MS : LOCAL_TIMEOUT_MS) });
|
|
2967
|
+
}
|
|
2968
|
+
/** G11: a thrown git or network failure still comes back as a sentence. */
|
|
2969
|
+
async guard(op, fn) {
|
|
2970
|
+
try {
|
|
2971
|
+
return await fn();
|
|
2972
|
+
} catch (e) {
|
|
2973
|
+
return fail(502, "git_failed", `The ${op} could not finish because a git or network step failed (${errText(e)}). Try again; if it keeps failing, tell the owner.`);
|
|
2974
|
+
}
|
|
2975
|
+
}
|
|
2976
|
+
gateChecks(channelId) {
|
|
2977
|
+
if (this.disabledDepth.has(channelId)) return fail(403, "git_disabled_for_turn", "Git tools are disabled for this turn. Describe the change in your reply instead, and a later turn can push it.");
|
|
2978
|
+
return null;
|
|
2979
|
+
}
|
|
2980
|
+
/** D11 + R15: exactly one grant (server or PAT) is used as is, whatever the worktree's
|
|
2981
|
+
* origin (a fork origin keeps working, as in 0.98.0). When every grant is for the same
|
|
2982
|
+
* repo (case-insensitive owner/name, e.g. a server grant plus a local PAT), the repo is
|
|
2983
|
+
* unambiguous: no origin lookup, and grantForRepo picks the server grant over the PAT.
|
|
2984
|
+
* With grants for two or more distinct repos, "the repo" is the
|
|
2985
|
+
* worktree's GitHub origin and its grant is the server grant when one exists, else the
|
|
2986
|
+
* local PAT (resolver.grantForRepo); an origin matching no grant is no_grant_for_repo and
|
|
2987
|
+
* no usable origin is no_grant_for_worktree. The grant is still the only source of the
|
|
2988
|
+
* push/fetch target (D8.1): the origin only selects among grants the owner already made,
|
|
2989
|
+
* never names a destination. Ops that do not need a worktree (open/merge PR without a
|
|
2990
|
+
* path) treat an unusable cwd as "no origin". */
|
|
2991
|
+
async resolveGrant(channelId, path2, needWorktree) {
|
|
2992
|
+
let grants;
|
|
2993
|
+
try {
|
|
2994
|
+
grants = await this.deps.resolver.listGrants();
|
|
2995
|
+
} catch (e) {
|
|
2996
|
+
return fail(502, "grants_unavailable", `Could not load this agent's git grants (${errText(e)}). Try again in a minute.`);
|
|
2997
|
+
}
|
|
2998
|
+
const where = `Agents > ${this.deps.agentName} > Git access`;
|
|
2999
|
+
if (grants.length === 0) return fail(404, "no_grant", `No git grant for this agent. Your owner can add one under ${where}.`);
|
|
3000
|
+
if (grants.length === 1) return { ok: true, grant: grants[0] };
|
|
3001
|
+
if (new Set(grants.map(repoKey)).size === 1) {
|
|
3002
|
+
const only = grants[0];
|
|
3003
|
+
let g2;
|
|
3004
|
+
try {
|
|
3005
|
+
g2 = await this.deps.resolver.grantForRepo(only.repoOwner, only.repoName);
|
|
3006
|
+
} catch (e) {
|
|
3007
|
+
return fail(502, "grants_unavailable", `Could not load this agent's git grants (${errText(e)}). Try again in a minute.`);
|
|
3008
|
+
}
|
|
3009
|
+
return { ok: true, grant: g2 ?? only };
|
|
3010
|
+
}
|
|
3011
|
+
const wt = this.worktree(channelId, path2);
|
|
3012
|
+
if (!wt.ok && (needWorktree || path2 !== void 0)) return wt;
|
|
3013
|
+
const origin = wt.ok ? await this.worktreeOrigin(wt) : null;
|
|
3014
|
+
if (origin && !origin.ok) return origin;
|
|
3015
|
+
const repo = origin?.repo ?? null;
|
|
3016
|
+
if (!repo) {
|
|
3017
|
+
const example = `${grants[0].repoOwner}/${grants[0].repoName}`;
|
|
3018
|
+
return fail(404, "no_grant_for_worktree", `This worktree has no GitHub origin remote and the agent has ${grants.length} git grants (${this.grantList(grants)}). Add the granted repository as origin (git remote add origin https://github.com/${example}.git, using the repository you are working on) so the gateway knows which grant applies.`);
|
|
3019
|
+
}
|
|
3020
|
+
let g;
|
|
3021
|
+
try {
|
|
3022
|
+
g = await this.deps.resolver.grantForRepo(repo.owner, repo.name);
|
|
3023
|
+
} catch (e) {
|
|
3024
|
+
return fail(502, "grants_unavailable", `Could not load this agent's git grants (${errText(e)}). Try again in a minute.`);
|
|
3025
|
+
}
|
|
3026
|
+
if (!g) return fail(404, "no_grant_for_repo", `This worktree's origin is ${repo.owner}/${repo.name}, and the agent has no git grant for that repository (its grants: ${this.grantList(grants)}). Your owner can add ${repo.owner}/${repo.name} under ${where}.`);
|
|
3027
|
+
return { ok: true, grant: g };
|
|
3028
|
+
}
|
|
3029
|
+
grantList(grants) {
|
|
3030
|
+
const byKey = /* @__PURE__ */ new Map();
|
|
3031
|
+
for (const x of grants) if (!byKey.has(repoKey(x))) byKey.set(repoKey(x), `${x.repoOwner}/${x.repoName}`);
|
|
3032
|
+
const names = [...byKey.values()];
|
|
3033
|
+
return names.slice(0, 10).join(", ") + (names.length > 10 ? `, and ${names.length - 10} more` : "");
|
|
3034
|
+
}
|
|
3035
|
+
/** R15: remote.origin.url of the validated worktree, read with the hardened runner while
|
|
3036
|
+
* the worktree identity is held; null repo = no usable (GitHub) origin. */
|
|
3037
|
+
async worktreeOrigin(wt) {
|
|
3038
|
+
const changed = fail(400, "invalid_path", "The worktree changed while its origin remote was being read (its .git was replaced or gained alternates or a config include). Make sure nothing else is modifying the repository, then try again.");
|
|
3039
|
+
const id = this.identity(wt);
|
|
3040
|
+
if (!id) return changed;
|
|
3041
|
+
const r = await this.git(["config", "--get", "remote.origin.url"], { gitDir: wt.gitDir, cwd: wt.path });
|
|
3042
|
+
if (!this.sameIdentity(wt, id)) return changed;
|
|
3043
|
+
return { ok: true, repo: r.code === 0 ? parseGitHubOrigin(r.stdout) : null };
|
|
3044
|
+
}
|
|
3045
|
+
/** Credential plus the effective policy. When the listed grant is LOOSER than the
|
|
3046
|
+
* policy the cached mint carries (the owner loosened the grant after the mint), the
|
|
3047
|
+
* cached credential is dropped and re-minted once so the loosening takes effect now
|
|
3048
|
+
* instead of at the next scheduled re-mint; the result is still combined
|
|
3049
|
+
* most-restrictively, so a relay that really is tighter keeps winning. */
|
|
3050
|
+
async credentialAndPolicy(g) {
|
|
3051
|
+
let cred = await this.credential(g);
|
|
3052
|
+
if (!cred.ok) return cred;
|
|
3053
|
+
if (cred.value.policy && this.looser(g, cred.value.policy)) {
|
|
3054
|
+
this.deps.resolver.dropCredential(g);
|
|
3055
|
+
cred = await this.credential(g);
|
|
3056
|
+
if (!cred.ok) return cred;
|
|
3057
|
+
}
|
|
3058
|
+
const pol = this.effectivePolicy(g, cred.value);
|
|
3059
|
+
if (!pol.ok) return pol;
|
|
3060
|
+
return { ok: true, value: cred.value, grant: pol.grant };
|
|
3061
|
+
}
|
|
3062
|
+
looser(g, p) {
|
|
3063
|
+
return isAutonomous(g) && p.mode !== "autonomous" || g.allowWorkflows && !p.allowWorkflows || g.allowRiskPaths && !p.allowRiskPaths || g.allowPublic && !p.allowPublic;
|
|
3064
|
+
}
|
|
3065
|
+
/** The mint's policy and the listed grant are combined most-restrictively, so a
|
|
3066
|
+
* tightening seen by either source takes effect at once. The repos must agree. */
|
|
3067
|
+
effectivePolicy(g, cred) {
|
|
3068
|
+
const p = cred.policy;
|
|
3069
|
+
if (!p) return { ok: true, grant: g };
|
|
3070
|
+
if (p.repo.toLowerCase() !== `${g.repoOwner}/${g.repoName}`.toLowerCase()) {
|
|
3071
|
+
this.deps.resolver.dropCredential(g);
|
|
3072
|
+
return fail(502, "credential_unavailable", `The token minted for this agent's grant does not match ${g.repoOwner}/${g.repoName}, so it was not used. Try again in a minute; if it keeps failing, ask the owner to check Git access for this agent.`);
|
|
3073
|
+
}
|
|
3074
|
+
return {
|
|
3075
|
+
ok: true,
|
|
3076
|
+
grant: {
|
|
3077
|
+
...g,
|
|
3078
|
+
mode: isAutonomous(g) && p.mode === "autonomous" ? "autonomous" : "pr-only",
|
|
3079
|
+
allowWorkflows: g.allowWorkflows && p.allowWorkflows,
|
|
3080
|
+
allowRiskPaths: g.allowRiskPaths && p.allowRiskPaths,
|
|
3081
|
+
allowPublic: g.allowPublic && p.allowPublic
|
|
3082
|
+
}
|
|
3083
|
+
};
|
|
3084
|
+
}
|
|
3085
|
+
/** Validates the model-supplied path, then uses only the realpath-resolved location. */
|
|
3086
|
+
worktree(channelId, path2) {
|
|
3087
|
+
const p = path2 ?? this.deps.cwdFor(channelId);
|
|
3088
|
+
const v = validateWorktree(p, this.deps.writableRoots());
|
|
3089
|
+
if (!v.ok) return fail(400, "invalid_path", `That path cannot be used for git (${v.reason}). Pass the path of a git checkout inside your working directory, or omit path to use the session directory.`);
|
|
3090
|
+
return { ok: true, path: dirname4(v.gitDir), gitDir: v.gitDir };
|
|
3091
|
+
}
|
|
3092
|
+
/** R13: what must not change between validation and the last use of the worktree. */
|
|
3093
|
+
identity(wt) {
|
|
3094
|
+
try {
|
|
3095
|
+
const d = lstatSync4(wt.path);
|
|
3096
|
+
const gd = lstatSync4(wt.gitDir);
|
|
3097
|
+
if (!d.isDirectory() || !gd.isDirectory()) return null;
|
|
3098
|
+
if (existsSync6(join7(wt.gitDir, "objects", "info", "alternates"))) return null;
|
|
3099
|
+
if (CONFIG_INCLUDE_RE.test(readConfig(wt.gitDir))) return null;
|
|
3100
|
+
return { dirDev: d.dev, dirIno: d.ino, gitDev: gd.dev, gitIno: gd.ino };
|
|
3101
|
+
} catch {
|
|
3102
|
+
return null;
|
|
3103
|
+
}
|
|
3104
|
+
}
|
|
3105
|
+
sameIdentity(wt, before) {
|
|
3106
|
+
const now = this.identity(wt);
|
|
3107
|
+
return !!before && !!now && before.dirDev === now.dirDev && before.dirIno === now.dirIno && before.gitDev === now.gitDev && before.gitIno === now.gitIno;
|
|
3108
|
+
}
|
|
3109
|
+
async worktreeChanged(op, mirror) {
|
|
3110
|
+
if (mirror) await this.git(["update-ref", "-d", "refs/braid/incoming"], { gitDir: mirror });
|
|
3111
|
+
return fail(400, "invalid_path", `The worktree changed while the ${op} was running (its .git was replaced or gained alternates), so nothing was ${op === "push" ? "pushed" : "fetched into it"}. Make sure nothing else is modifying the repository, then try again.`);
|
|
3112
|
+
}
|
|
3113
|
+
/** Only a refusal-text hint: the worktree's origin/HEAD, if the path validates. */
|
|
3114
|
+
async worktreeDefaultHint(channelId, path2) {
|
|
3115
|
+
const wt = this.worktree(channelId, path2);
|
|
3116
|
+
if (!wt.ok) return null;
|
|
3117
|
+
const r = await this.git(["symbolic-ref", "-q", "--short", "refs/remotes/origin/HEAD"], { gitDir: wt.gitDir, cwd: wt.path });
|
|
3118
|
+
const s = r.stdout.trim();
|
|
3119
|
+
return r.code === 0 && s.startsWith("origin/") ? s.slice("origin/".length) : null;
|
|
3120
|
+
}
|
|
3121
|
+
async credential(g) {
|
|
3122
|
+
try {
|
|
3123
|
+
return { ok: true, value: await this.deps.resolver.credentialFor(g) };
|
|
3124
|
+
} catch (e) {
|
|
3125
|
+
return fail(502, "credential_unavailable", `Could not get a GitHub credential for ${g.repoOwner}/${g.repoName} (${errText(e)}). Try again in a minute; if it keeps failing, ask the owner to check Git access for this agent.`);
|
|
3126
|
+
}
|
|
3127
|
+
}
|
|
3128
|
+
/** Visibility and default branch: from the mint for app grants; looked up for a PAT. */
|
|
3129
|
+
async repoMeta(g, cred) {
|
|
3130
|
+
let meta;
|
|
3131
|
+
if (cred.private !== null && cred.defaultBranch) {
|
|
3132
|
+
meta = { private: cred.private, defaultBranch: cred.defaultBranch };
|
|
3133
|
+
} else {
|
|
3134
|
+
const res = await this.api(g, cred.token, "GET", `/repos/${this.repoPath(g)}`);
|
|
3135
|
+
if (!res.ok) return res;
|
|
3136
|
+
const j = res.json;
|
|
3137
|
+
meta = {
|
|
3138
|
+
private: cred.private ?? (typeof j.private === "boolean" ? j.private : null),
|
|
3139
|
+
defaultBranch: cred.defaultBranch ?? (typeof j.default_branch === "string" ? j.default_branch : null)
|
|
3140
|
+
};
|
|
3141
|
+
}
|
|
3142
|
+
if (meta.defaultBranch) this.knownDefault.set(`${g.repoOwner}/${g.repoName}`, meta.defaultBranch);
|
|
3143
|
+
return { ok: true, value: meta };
|
|
3144
|
+
}
|
|
3145
|
+
/** D9: fail closed when visibility is unknown. */
|
|
3146
|
+
publicCheck(g, meta) {
|
|
3147
|
+
if (meta.private === true || g.allowPublic) return null;
|
|
3148
|
+
const why = meta.private === false ? "is public" : "has unknown visibility";
|
|
3149
|
+
return fail(403, "public_repo_not_allowed", `${g.repoOwner}/${g.repoName} ${why}, and the grant does not allow writes to public repositories. Ask the owner to allow public repositories on the grant if this is intended.`);
|
|
3150
|
+
}
|
|
3151
|
+
/** D8.2: the regex, then git's own rules (trailing "/", "//", trailing ".", "/.x", "@{"). */
|
|
3152
|
+
async checkBranch(branch) {
|
|
3153
|
+
const bad = fail(400, "invalid_branch", `Branch name "${branch.slice(0, 200)}" is not allowed. Use letters, digits, ".", "_", "-" and "/", without "..", "//", a leading "-", a trailing "/" or ".", a component starting with ".", a ".lock" suffix, a "refs/" prefix, or the name HEAD.`);
|
|
3154
|
+
if (!BRANCH_RE.test(branch)) return bad;
|
|
3155
|
+
const r = await this.git(["check-ref-format", "--branch", branch], {});
|
|
3156
|
+
return r.code === 0 ? null : bad;
|
|
3157
|
+
}
|
|
3158
|
+
defaultProtected(branch) {
|
|
3159
|
+
return fail(403, "default_branch_protected", `"${branch}" is the default branch; pr-only mode never pushes there. Push to a branch under ${this.namespace()} and open a pull request instead.`);
|
|
3160
|
+
}
|
|
3161
|
+
outsideNamespace() {
|
|
3162
|
+
const ns = this.namespace();
|
|
3163
|
+
return fail(403, "branch_outside_namespace", `This repo is in pr-only mode: push only to branches under ${ns} (for example ${ns}my-change). A human merges from a pull request.`);
|
|
3164
|
+
}
|
|
3165
|
+
mergeRefused(num) {
|
|
3166
|
+
return fail(403, "merge_not_allowed_in_pr_only", `This repo is in pr-only mode, so a human merges pull request #${num}. Tell the owner it is ready for review.`);
|
|
3167
|
+
}
|
|
3168
|
+
namespace() {
|
|
3169
|
+
return `braid/${this.deps.agentName}/`;
|
|
3170
|
+
}
|
|
3171
|
+
rulesText(g) {
|
|
3172
|
+
const parts = [];
|
|
3173
|
+
const auto = isAutonomous(g);
|
|
3174
|
+
if (!auto) parts.push(`push only to branches under ${this.namespace()}, never the default branch; pull requests open as drafts and a human merges`);
|
|
3175
|
+
else parts.push("push to any branch, including the default branch; braid_merge_pr is allowed");
|
|
3176
|
+
parts.push("fast-forward pushes only, no deletes, no tags");
|
|
3177
|
+
parts.push(g.allowWorkflows ? "changes under .github/workflows are allowed" : "changes under .github/workflows are refused");
|
|
3178
|
+
if (!auto) parts.push(g.allowRiskPaths ? "risky files (package manifests, lockfiles, Makefile, root scripts, .git* files) are allowed" : "risky files (package manifests, lockfiles, Makefile, root scripts, .git* files) need the owner's approval");
|
|
3179
|
+
parts.push(g.allowPublic ? "public repositories are allowed" : "writes to a public repository are refused");
|
|
3180
|
+
return parts.join("; ") + ".";
|
|
3181
|
+
}
|
|
3182
|
+
async resolveSha(wt, rev) {
|
|
3183
|
+
const bad = fail(400, "invalid_sha", `Could not resolve "${String(rev).slice(0, 100)}" to a commit in ${wt.path}. Commit your work first, or pass a commit sha or branch name that exists there.`);
|
|
3184
|
+
if (typeof rev !== "string" || !rev || rev.startsWith("-") || /[\0\n]/.test(rev)) return bad;
|
|
3185
|
+
const r = await this.git(["rev-parse", "--verify", "-q", "--end-of-options", `${rev}^{commit}`], { gitDir: wt.gitDir, cwd: wt.path });
|
|
3186
|
+
const sha = r.stdout.trim();
|
|
3187
|
+
if (r.code !== 0 || !/^[0-9a-f]{40,64}$/.test(sha)) return bad;
|
|
3188
|
+
return { ok: true, sha };
|
|
3189
|
+
}
|
|
3190
|
+
mirrorPath(g) {
|
|
3191
|
+
return join7(this.deps.configDir, "git", relayHostSegment(this.deps.relayUrl), safeSegment(this.deps.agentName), `${safeSegment(g.repoOwner)}__${safeSegment(g.repoName)}.git`);
|
|
3192
|
+
}
|
|
3193
|
+
/** Creates the bare mirror (0700) when absent and removes any stale credential file. */
|
|
3194
|
+
async ensureMirror(mirror) {
|
|
3195
|
+
if (!existsSync6(join7(mirror, "HEAD"))) {
|
|
3196
|
+
mkdirSync5(mirror, { recursive: true, mode: 448 });
|
|
3197
|
+
const r = await this.git(["init", "--bare", "--quiet", mirror], {});
|
|
3198
|
+
if (r.code !== 0) return fail(502, "mirror_init_failed", `Could not prepare the bridge's git mirror: ${oneLine(r.stderr)}.`);
|
|
3199
|
+
}
|
|
3200
|
+
try {
|
|
3201
|
+
chmodSync4(mirror, 448);
|
|
3202
|
+
} catch {
|
|
3203
|
+
}
|
|
3204
|
+
this.removeCredFile(join7(mirror, ".braid-cred"));
|
|
3205
|
+
return null;
|
|
3206
|
+
}
|
|
3207
|
+
remoteUrl(g) {
|
|
3208
|
+
return this.deps.remoteUrlFor ? this.deps.remoteUrlFor(g) : `https://github.com/${this.repoPath(g)}.git`;
|
|
3209
|
+
}
|
|
3210
|
+
repoPath(g) {
|
|
3211
|
+
return `${encodeURIComponent(g.repoOwner)}/${encodeURIComponent(g.repoName)}`;
|
|
3212
|
+
}
|
|
3213
|
+
writeCredFile(file, token) {
|
|
3214
|
+
writeFileSync5(file, `https://x-access-token:${token}@github.com
|
|
3215
|
+
`, { mode: 384 });
|
|
3216
|
+
try {
|
|
3217
|
+
chmodSync4(file, 384);
|
|
3218
|
+
} catch {
|
|
3219
|
+
}
|
|
3220
|
+
}
|
|
3221
|
+
removeCredFile(file) {
|
|
3222
|
+
if (!existsSync6(file)) return;
|
|
3223
|
+
try {
|
|
3224
|
+
writeFileSync5(file, "");
|
|
3225
|
+
} catch {
|
|
3226
|
+
}
|
|
3227
|
+
try {
|
|
3228
|
+
unlinkSync3(file);
|
|
3229
|
+
} catch {
|
|
3230
|
+
}
|
|
3231
|
+
}
|
|
3232
|
+
remoteRejected(g, stderr) {
|
|
3233
|
+
this.deps.resolver.dropCredential(g);
|
|
3234
|
+
return fail(502, "remote_rejected", `GitHub refused the request for ${g.repoOwner}/${g.repoName} or could not be reached (${oneLine(stderr, 200)}). The cached token was dropped and a fresh one is minted on the next try; if it keeps failing, ask the owner to check Git access for this agent.`);
|
|
3235
|
+
}
|
|
3236
|
+
async api(g, token, method, path2, body) {
|
|
3237
|
+
let res;
|
|
3238
|
+
try {
|
|
3239
|
+
res = await this.fetchFn(`${GITHUB_API}${path2}`, {
|
|
3240
|
+
method,
|
|
3241
|
+
headers: {
|
|
3242
|
+
Authorization: `Bearer ${token}`,
|
|
3243
|
+
Accept: "application/vnd.github+json",
|
|
3244
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
3245
|
+
"User-Agent": "braid-bridge",
|
|
3246
|
+
...body !== void 0 ? { "Content-Type": "application/json" } : {}
|
|
3247
|
+
},
|
|
3248
|
+
...body !== void 0 ? { body: JSON.stringify(body) } : {}
|
|
3249
|
+
});
|
|
3250
|
+
} catch (e) {
|
|
3251
|
+
return fail(502, "github_unreachable", `Could not reach the GitHub API (${errText(e)}). Try again in a minute.`);
|
|
3252
|
+
}
|
|
3253
|
+
let json = null;
|
|
3254
|
+
try {
|
|
3255
|
+
json = await res.json();
|
|
3256
|
+
} catch {
|
|
3257
|
+
json = null;
|
|
3258
|
+
}
|
|
3259
|
+
if (!res.ok) {
|
|
3260
|
+
if (res.status === 401) this.deps.resolver.dropCredential(g);
|
|
3261
|
+
const msg = json && typeof json === "object" && typeof json.message === "string" ? json.message : res.statusText;
|
|
3262
|
+
const errs = json && typeof json === "object" && Array.isArray(json.errors) ? json.errors.map((e) => typeof e?.message === "string" ? e.message : "").filter(Boolean).join("; ") : "";
|
|
3263
|
+
return fail(502, "github_rejected", `GitHub answered ${res.status} for ${g.repoOwner}/${g.repoName}: ${oneLine(msg + (errs ? ` (${errs})` : ""))}. Fix the request or check the pull request on GitHub, then try again.`);
|
|
3264
|
+
}
|
|
3265
|
+
return { ok: true, json: json ?? {} };
|
|
3266
|
+
}
|
|
3267
|
+
/** One mirror operation at a time: the incoming/remote refs and the credential file are shared. */
|
|
3268
|
+
serialize(key, fn) {
|
|
3269
|
+
const prev = this.locks.get(key) ?? Promise.resolve();
|
|
3270
|
+
const run = prev.then(fn, fn);
|
|
3271
|
+
const tail = run.then(() => void 0, () => void 0);
|
|
3272
|
+
this.locks.set(key, tail);
|
|
3273
|
+
void tail.then(() => {
|
|
3274
|
+
if (this.locks.get(key) === tail) this.locks.delete(key);
|
|
3275
|
+
});
|
|
3276
|
+
return run;
|
|
3277
|
+
}
|
|
3278
|
+
log(line) {
|
|
3279
|
+
const l = scrub(line);
|
|
3280
|
+
if (this.deps.log) this.deps.log(l);
|
|
3281
|
+
else console.log(`[git-gateway] ${l}`);
|
|
3282
|
+
}
|
|
3283
|
+
};
|
|
3284
|
+
|
|
3285
|
+
// src/gitGateway/grants.ts
|
|
3286
|
+
import { chmodSync as chmodSync5, existsSync as existsSync7, mkdirSync as mkdirSync6, readFileSync as readFileSync8, renameSync as renameSync3, unlinkSync as unlinkSync4, writeFileSync as writeFileSync6 } from "fs";
|
|
3287
|
+
import { dirname as dirname5, join as join8 } from "path";
|
|
3288
|
+
var GRANTS_TTL_MS = 6e4;
|
|
3289
|
+
var REMINT_WINDOW_MS = 5 * 6e4;
|
|
3290
|
+
function localGitGrantPath(dir, relayUrl, agentName) {
|
|
3291
|
+
const safeName = agentName.replace(/[^a-zA-Z0-9._-]/g, "_");
|
|
3292
|
+
return join8(dir, "git-grants", relayHostSegment(relayUrl), `${safeName}.json`);
|
|
3293
|
+
}
|
|
3294
|
+
function loadLocalGitGrant(dir, relayUrl, agentName) {
|
|
3295
|
+
let raw;
|
|
3296
|
+
try {
|
|
3297
|
+
raw = JSON.parse(readFileSync8(localGitGrantPath(dir, relayUrl, agentName), "utf8"));
|
|
3298
|
+
} catch {
|
|
3299
|
+
return null;
|
|
3300
|
+
}
|
|
3301
|
+
const o = raw;
|
|
3302
|
+
if (!o || typeof o !== "object") return null;
|
|
3303
|
+
if (typeof o.token !== "string" || !o.token) return null;
|
|
3304
|
+
if (typeof o.repoOwner !== "string" || !o.repoOwner || typeof o.repoName !== "string" || !o.repoName) return null;
|
|
3305
|
+
if (o.mode !== "autonomous" && o.mode !== "pr-only") return null;
|
|
3306
|
+
return {
|
|
3307
|
+
source: "pat",
|
|
3308
|
+
repoOwner: o.repoOwner,
|
|
3309
|
+
repoName: o.repoName,
|
|
3310
|
+
mode: o.mode,
|
|
3311
|
+
allowWorkflows: o.allowWorkflows === true,
|
|
3312
|
+
allowRiskPaths: o.allowRiskPaths === true,
|
|
3313
|
+
allowPublic: o.allowPublic === true,
|
|
3314
|
+
token: o.token
|
|
3315
|
+
};
|
|
3316
|
+
}
|
|
3317
|
+
function saveLocalGitGrant(dir, relayUrl, agentName, grant, deps) {
|
|
3318
|
+
const file = localGitGrantPath(dir, relayUrl, agentName);
|
|
3319
|
+
mkdirSync6(dirname5(file), { recursive: true, mode: 448 });
|
|
3320
|
+
const body = {
|
|
3321
|
+
repoOwner: grant.repoOwner,
|
|
3322
|
+
repoName: grant.repoName,
|
|
3323
|
+
mode: grant.mode,
|
|
3324
|
+
allowWorkflows: grant.allowWorkflows,
|
|
3325
|
+
allowRiskPaths: grant.allowRiskPaths,
|
|
3326
|
+
allowPublic: grant.allowPublic,
|
|
3327
|
+
token: grant.token
|
|
3328
|
+
};
|
|
3329
|
+
const tmp = `${file}.tmp-${process.pid}`;
|
|
3330
|
+
try {
|
|
3331
|
+
writeFileSync6(tmp, JSON.stringify(body, null, 2) + "\n", { mode: 384 });
|
|
3332
|
+
renameSync3(tmp, file);
|
|
3333
|
+
} catch (err) {
|
|
3334
|
+
try {
|
|
3335
|
+
unlinkSync4(tmp);
|
|
3336
|
+
} catch {
|
|
3337
|
+
}
|
|
3338
|
+
throw err;
|
|
3339
|
+
}
|
|
3340
|
+
try {
|
|
3341
|
+
chmodSync5(file, 384);
|
|
3342
|
+
} catch {
|
|
3343
|
+
}
|
|
3344
|
+
const platform = deps?.platform ?? process.platform;
|
|
3345
|
+
if (platform === "win32") {
|
|
3346
|
+
const d = deps ?? productionWin32Deps();
|
|
3347
|
+
try {
|
|
3348
|
+
applyWin32Acl(file, resolveCurrentUser(d).sid, "file", d);
|
|
3349
|
+
} catch (err) {
|
|
3350
|
+
console.warn(`[braid] warn: could not set ACL on git grant file ${file} (startup gate will enforce on next start): ${err instanceof Error ? err.message : String(err)}`);
|
|
3351
|
+
}
|
|
3352
|
+
}
|
|
3353
|
+
return file;
|
|
3354
|
+
}
|
|
3355
|
+
function clearLocalGitGrant(dir, relayUrl, agentName) {
|
|
3356
|
+
const file = localGitGrantPath(dir, relayUrl, agentName);
|
|
3357
|
+
if (!existsSync7(file)) return false;
|
|
3358
|
+
try {
|
|
3359
|
+
unlinkSync4(file);
|
|
3360
|
+
return true;
|
|
3361
|
+
} catch {
|
|
3362
|
+
return false;
|
|
3363
|
+
}
|
|
3364
|
+
}
|
|
3365
|
+
var GrantResolver = class {
|
|
3366
|
+
constructor(deps) {
|
|
3367
|
+
this.deps = deps;
|
|
3368
|
+
this.now = deps.now ?? Date.now;
|
|
3369
|
+
}
|
|
3370
|
+
deps;
|
|
3371
|
+
grantsCache = null;
|
|
3372
|
+
// #private so an inspect/dump of the resolver can never print a token.
|
|
3373
|
+
#creds = /* @__PURE__ */ new Map();
|
|
3374
|
+
#pending = /* @__PURE__ */ new Map();
|
|
3375
|
+
now;
|
|
3376
|
+
async listGrants() {
|
|
3377
|
+
const out = [];
|
|
3378
|
+
if (this.deps.relay) {
|
|
3379
|
+
const t = this.now();
|
|
3380
|
+
if (!this.grantsCache || t - this.grantsCache.at >= GRANTS_TTL_MS) {
|
|
3381
|
+
const fresh = await this.deps.relay.fetchGitGrants();
|
|
3382
|
+
if (fresh !== null) this.grantsCache = { at: t, grants: fresh };
|
|
3383
|
+
}
|
|
3384
|
+
if (this.grantsCache) out.push(...this.grantsCache.grants);
|
|
3385
|
+
}
|
|
3386
|
+
const pat = loadLocalGitGrant(this.deps.configDir, this.deps.relayUrl, this.deps.agentName);
|
|
3387
|
+
if (pat) {
|
|
3388
|
+
const { token: _token, ...info } = pat;
|
|
3389
|
+
out.push(info);
|
|
3390
|
+
}
|
|
3391
|
+
return out;
|
|
3392
|
+
}
|
|
3393
|
+
/** D11: a server (app) grant for the repo wins; otherwise the local PAT grant.
|
|
3394
|
+
* GitHub owner and repo names are case-insensitive. */
|
|
3395
|
+
async grantForRepo(owner, name) {
|
|
3396
|
+
const o = owner.toLowerCase();
|
|
3397
|
+
const n = name.toLowerCase();
|
|
3398
|
+
const matches = (await this.listGrants()).filter((g) => g.repoOwner.toLowerCase() === o && g.repoName.toLowerCase() === n);
|
|
3399
|
+
return matches.find((g) => g.source === "app") ?? matches.find((g) => g.source === "pat") ?? null;
|
|
3400
|
+
}
|
|
3401
|
+
async credentialFor(grant) {
|
|
3402
|
+
if (grant.source === "pat") {
|
|
3403
|
+
const pat = loadLocalGitGrant(this.deps.configDir, this.deps.relayUrl, this.deps.agentName);
|
|
3404
|
+
if (!pat) throw new Error("git grant: the local PAT grant is no longer present");
|
|
3405
|
+
if (pat.repoOwner.toLowerCase() !== grant.repoOwner.toLowerCase() || pat.repoName.toLowerCase() !== grant.repoName.toLowerCase()) {
|
|
3406
|
+
throw new Error(`git grant: the local PAT grant does not cover ${grant.repoOwner}/${grant.repoName}`);
|
|
3407
|
+
}
|
|
3408
|
+
return { token: pat.token, expiresAt: Infinity, private: null, defaultBranch: null };
|
|
3409
|
+
}
|
|
3410
|
+
const id = grant.id;
|
|
3411
|
+
if (!id || !this.deps.relay) throw new Error("git grant: no relay available to mint a token for this grant");
|
|
3412
|
+
const cached = this.#creds.get(id);
|
|
3413
|
+
if (cached && cached.expiresAt - this.now() > REMINT_WINDOW_MS) return cached;
|
|
3414
|
+
const inflight = this.#pending.get(id);
|
|
3415
|
+
if (inflight) return inflight;
|
|
3416
|
+
const relay = this.deps.relay;
|
|
3417
|
+
const p = (async () => {
|
|
3418
|
+
const m = await relay.mintGitToken(id);
|
|
3419
|
+
if (!m) throw new Error(`git grant: the relay would not mint a token for ${grant.repoOwner}/${grant.repoName}`);
|
|
3420
|
+
const cred = {
|
|
3421
|
+
token: m.token,
|
|
3422
|
+
expiresAt: m.expiresAt,
|
|
3423
|
+
private: m.private,
|
|
3424
|
+
defaultBranch: m.defaultBranch,
|
|
3425
|
+
policy: { repo: m.repo, mode: m.mode, allowWorkflows: m.allowWorkflows, allowRiskPaths: m.allowRiskPaths, allowPublic: m.allowPublic }
|
|
3426
|
+
};
|
|
3427
|
+
this.#creds.set(id, cred);
|
|
3428
|
+
return cred;
|
|
3429
|
+
})();
|
|
3430
|
+
this.#pending.set(id, p);
|
|
3431
|
+
try {
|
|
3432
|
+
return await p;
|
|
3433
|
+
} finally {
|
|
3434
|
+
this.#pending.delete(id);
|
|
3435
|
+
}
|
|
3436
|
+
}
|
|
3437
|
+
dropCredential(grant) {
|
|
3438
|
+
if (grant.id) this.#creds.delete(grant.id);
|
|
3439
|
+
}
|
|
3440
|
+
};
|
|
3441
|
+
|
|
2451
3442
|
// src/bridge.ts
|
|
2452
3443
|
var REFRESH_MARGIN_MS = 24 * 60 * 60 * 1e3;
|
|
2453
3444
|
var MIN_DELAY_MS = 60 * 1e3;
|
|
@@ -2459,6 +3450,15 @@ function computeRefreshDelay(exp, now) {
|
|
|
2459
3450
|
}
|
|
2460
3451
|
async function startBridge(cfg, relay, deps) {
|
|
2461
3452
|
const sessions = /* @__PURE__ */ new Map();
|
|
3453
|
+
const gitConfigDir = configDir(process.env);
|
|
3454
|
+
const gitGateway = deps.gitGateway ?? new GitGateway({
|
|
3455
|
+
resolver: new GrantResolver({ relay, configDir: gitConfigDir, relayUrl: cfg.relayWsUrl, agentName: cfg.agentName }),
|
|
3456
|
+
configDir: gitConfigDir,
|
|
3457
|
+
relayUrl: cfg.relayWsUrl,
|
|
3458
|
+
agentName: cfg.agentName,
|
|
3459
|
+
cwdFor: (ch) => handoffCwdFor(cfg, ch),
|
|
3460
|
+
writableRoots: () => resolveScope(cfg, cfg.agent, process.env).runtimeConfig.filesystem.allowWrite
|
|
3461
|
+
});
|
|
2462
3462
|
const channelNames = /* @__PURE__ */ new Map();
|
|
2463
3463
|
const hub = new ObservationHub();
|
|
2464
3464
|
const service = process.env.BRAID_SERVICE === "1";
|
|
@@ -2676,7 +3676,11 @@ async function startBridge(cfg, relay, deps) {
|
|
|
2676
3676
|
// merged ChannelSession constructor param order (session.ts).
|
|
2677
3677
|
deps.makeEphemeralAdapter ? () => deps.makeEphemeralAdapter(cfg) : void 0
|
|
2678
3678
|
);
|
|
2679
|
-
const broker = new SourceBroker(channelId, relay);
|
|
3679
|
+
const broker = new SourceBroker(channelId, relay, process.platform, gitGateway);
|
|
3680
|
+
session.onLisaFreshTurn = (active) => {
|
|
3681
|
+
broker.setGitEnabled(!active);
|
|
3682
|
+
gitGateway.setChannelGitEnabled(channelId, !active);
|
|
3683
|
+
};
|
|
2680
3684
|
const brokerInfo = await broker.start();
|
|
2681
3685
|
const allowedReactions = await relay.fetchAllowedReactions?.().catch(() => []) ?? [];
|
|
2682
3686
|
const mcpServers = [{
|
|
@@ -3015,10 +4019,11 @@ function startUpdateNotifier(opts) {
|
|
|
3015
4019
|
}
|
|
3016
4020
|
|
|
3017
4021
|
// src/cli.ts
|
|
3018
|
-
import { readFileSync as
|
|
4022
|
+
import { readFileSync as readFileSync12 } from "fs";
|
|
3019
4023
|
import { fileURLToPath as fileURLToPath3 } from "url";
|
|
3020
4024
|
|
|
3021
4025
|
// src/cliArgs.ts
|
|
4026
|
+
var GIT_REPO_RE = /^[A-Za-z0-9_.-]{1,100}\/[A-Za-z0-9_.-]{1,100}$/;
|
|
3022
4027
|
var USAGE = `Usage: braid <command>
|
|
3023
4028
|
|
|
3024
4029
|
Commands:
|
|
@@ -3048,6 +4053,14 @@ Commands:
|
|
|
3048
4053
|
scope allow-code-hosts <agent>
|
|
3049
4054
|
Let THIS agent's sandbox reach github.com/gitlab.com/bitbucket.org
|
|
3050
4055
|
directly (accepts the exfiltration risk; prefer the git tools)
|
|
4056
|
+
git-auth set <agent> <owner/repo> --mode <autonomous|pr-only>
|
|
4057
|
+
[--allow-workflows] [--allow-risk-paths] [--allow-public] --token-stdin
|
|
4058
|
+
Give a saved agent git access to ONE repo with a fine-grained
|
|
4059
|
+
token read from stdin (local fallback; prefer the Braid GitHub
|
|
4060
|
+
App in the web app). autonomous = push anywhere and merge;
|
|
4061
|
+
pr-only = braid/<agent>/* branches, a human merges.
|
|
4062
|
+
git-auth show <agent> Show the agent's local git grant (never the token)
|
|
4063
|
+
git-auth clear <agent> Remove the agent's local git grant
|
|
3051
4064
|
telemetry [status|enable|disable]
|
|
3052
4065
|
Show or change usage telemetry (tool-mode +
|
|
3053
4066
|
blocked-tool-call signals only; never content, paths, or
|
|
@@ -3241,6 +4254,71 @@ function parseCliArgs(argv) {
|
|
|
3241
4254
|
if (first === "version" || first === "--version" || first === "-v") {
|
|
3242
4255
|
return { kind: "version" };
|
|
3243
4256
|
}
|
|
4257
|
+
if (first === "git-auth") {
|
|
4258
|
+
const verb = argv[1];
|
|
4259
|
+
const rest = argv.slice(2);
|
|
4260
|
+
if (verb === "show" || verb === "clear") {
|
|
4261
|
+
const agent2 = rest[0] && !rest[0].startsWith("-") ? rest[0].trim() : void 0;
|
|
4262
|
+
if (!agent2 || rest.length > 1) return { kind: "usage", error: `Usage: braid git-auth ${verb} <agent>` };
|
|
4263
|
+
return verb === "show" ? { kind: "git-auth-show", agent: agent2 } : { kind: "git-auth-clear", agent: agent2 };
|
|
4264
|
+
}
|
|
4265
|
+
if (verb !== "set") return { kind: "usage", error: "Usage: braid git-auth <set|show|clear> <agent> ..." };
|
|
4266
|
+
let agent;
|
|
4267
|
+
let repo;
|
|
4268
|
+
let mode;
|
|
4269
|
+
let tokenStdin = false;
|
|
4270
|
+
let allowWorkflows = false;
|
|
4271
|
+
let allowRiskPaths = false;
|
|
4272
|
+
let allowPublic = false;
|
|
4273
|
+
for (let i = 0; i < rest.length; i++) {
|
|
4274
|
+
const a = rest[i];
|
|
4275
|
+
if (a === "--token-stdin") {
|
|
4276
|
+
tokenStdin = true;
|
|
4277
|
+
continue;
|
|
4278
|
+
}
|
|
4279
|
+
if (a === "--allow-workflows") {
|
|
4280
|
+
allowWorkflows = true;
|
|
4281
|
+
continue;
|
|
4282
|
+
}
|
|
4283
|
+
if (a === "--allow-risk-paths") {
|
|
4284
|
+
allowRiskPaths = true;
|
|
4285
|
+
continue;
|
|
4286
|
+
}
|
|
4287
|
+
if (a === "--allow-public") {
|
|
4288
|
+
allowPublic = true;
|
|
4289
|
+
continue;
|
|
4290
|
+
}
|
|
4291
|
+
if (a === "--mode") {
|
|
4292
|
+
const v = rest[i + 1]?.trim();
|
|
4293
|
+
if (v === void 0 || v === "" || v.startsWith("--")) return { kind: "usage", error: "Missing value for --mode (autonomous or pr-only)" };
|
|
4294
|
+
if (v !== "autonomous" && v !== "pr-only") return { kind: "usage", error: "Invalid --mode. Expected autonomous or pr-only" };
|
|
4295
|
+
mode = v;
|
|
4296
|
+
i++;
|
|
4297
|
+
continue;
|
|
4298
|
+
}
|
|
4299
|
+
if (a.startsWith("-")) return { kind: "usage", error: `Unknown flag for git-auth set: ${a}` };
|
|
4300
|
+
if (agent === void 0) {
|
|
4301
|
+
agent = a.trim();
|
|
4302
|
+
continue;
|
|
4303
|
+
}
|
|
4304
|
+
if (repo === void 0) {
|
|
4305
|
+
repo = a.trim();
|
|
4306
|
+
continue;
|
|
4307
|
+
}
|
|
4308
|
+
return {
|
|
4309
|
+
kind: "usage",
|
|
4310
|
+
error: "git-auth set does not accept the token as an argument. Pipe it on stdin: <command that prints the token> | braid git-auth set <agent> <owner/repo> --mode <mode> --token-stdin"
|
|
4311
|
+
};
|
|
4312
|
+
}
|
|
4313
|
+
if (!agent || !repo) return { kind: "usage", error: "Usage: braid git-auth set <agent> <owner/repo> --mode <autonomous|pr-only> --token-stdin" };
|
|
4314
|
+
if (!GIT_REPO_RE.test(repo)) return { kind: "usage", error: "git-auth set needs <owner/repo> (letters, digits, . _ -)" };
|
|
4315
|
+
if (!mode) return { kind: "usage", error: "git-auth set requires --mode <autonomous|pr-only>" };
|
|
4316
|
+
if (!tokenStdin) {
|
|
4317
|
+
return { kind: "usage", error: "git-auth set requires --token-stdin (the token is read from stdin, never from arguments)." };
|
|
4318
|
+
}
|
|
4319
|
+
const [repoOwner, repoName] = repo.split("/");
|
|
4320
|
+
return { kind: "git-auth-set", agent, repoOwner, repoName, mode, allowWorkflows, allowRiskPaths, allowPublic };
|
|
4321
|
+
}
|
|
3244
4322
|
if (first === "enroll") {
|
|
3245
4323
|
let inviteStdin = false;
|
|
3246
4324
|
let json = false;
|
|
@@ -3302,14 +4380,14 @@ function parseCliArgs(argv) {
|
|
|
3302
4380
|
// src/launchTui.ts
|
|
3303
4381
|
import { spawn } from "child_process";
|
|
3304
4382
|
import { createRequire } from "module";
|
|
3305
|
-
import { dirname as
|
|
4383
|
+
import { dirname as dirname6, join as join9 } from "path";
|
|
3306
4384
|
function resolveTuiEntry() {
|
|
3307
4385
|
try {
|
|
3308
4386
|
const require2 = createRequire(import.meta.url);
|
|
3309
4387
|
const pkgPath = require2.resolve("@snowyroad/braid-tui/package.json");
|
|
3310
4388
|
const pkg = require2("@snowyroad/braid-tui/package.json");
|
|
3311
4389
|
const rel = typeof pkg.bin === "string" ? pkg.bin : pkg.bin?.["braid-tui"];
|
|
3312
|
-
return rel ?
|
|
4390
|
+
return rel ? join9(dirname6(pkgPath), rel) : null;
|
|
3313
4391
|
} catch {
|
|
3314
4392
|
return null;
|
|
3315
4393
|
}
|
|
@@ -3321,17 +4399,17 @@ function runTui(args, env, selfCliPath) {
|
|
|
3321
4399
|
return Promise.resolve(1);
|
|
3322
4400
|
}
|
|
3323
4401
|
const childEnv = { ...env, ARP_TUI_BRIDGE_CMD: `${process.execPath} ${selfCliPath}` };
|
|
3324
|
-
return new Promise((
|
|
4402
|
+
return new Promise((resolve2) => {
|
|
3325
4403
|
const child = spawn(process.execPath, [entry, ...args], { stdio: "inherit", env: childEnv });
|
|
3326
|
-
child.on("error", () =>
|
|
3327
|
-
child.on("close", (code) =>
|
|
4404
|
+
child.on("error", () => resolve2(1));
|
|
4405
|
+
child.on("close", (code) => resolve2(code ?? 1));
|
|
3328
4406
|
});
|
|
3329
4407
|
}
|
|
3330
4408
|
|
|
3331
4409
|
// src/enroll.ts
|
|
3332
4410
|
var ENROLL_STDIN_MAX_BYTES = 16 * 1024;
|
|
3333
|
-
function readBoundedStdin(stream, maxBytes) {
|
|
3334
|
-
return new Promise((
|
|
4411
|
+
function readBoundedStdin(stream, maxBytes, label = "invite") {
|
|
4412
|
+
return new Promise((resolve2, reject) => {
|
|
3335
4413
|
const chunks = [];
|
|
3336
4414
|
let total = 0;
|
|
3337
4415
|
let settled = false;
|
|
@@ -3347,12 +4425,12 @@ function readBoundedStdin(stream, maxBytes) {
|
|
|
3347
4425
|
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
3348
4426
|
total += buf.length;
|
|
3349
4427
|
if (total > maxBytes) {
|
|
3350
|
-
finish(() => reject(new Error(`stdin exceeded the ${maxBytes / 1024}KiB
|
|
4428
|
+
finish(() => reject(new Error(`stdin exceeded the ${maxBytes / 1024}KiB ${label} limit`)));
|
|
3351
4429
|
return;
|
|
3352
4430
|
}
|
|
3353
4431
|
chunks.push(buf);
|
|
3354
4432
|
};
|
|
3355
|
-
const onEnd = () => finish(() =>
|
|
4433
|
+
const onEnd = () => finish(() => resolve2(Buffer.concat(chunks).toString("utf8").trim()));
|
|
3356
4434
|
const onError = (err) => finish(() => reject(err));
|
|
3357
4435
|
stream.on("data", onData);
|
|
3358
4436
|
stream.on("end", onEnd);
|
|
@@ -3414,7 +4492,7 @@ async function runEnroll(opts, io, env) {
|
|
|
3414
4492
|
...opts.model ? { model: opts.model } : {}
|
|
3415
4493
|
};
|
|
3416
4494
|
const file = persistOrNeedsHuman(dir, stored);
|
|
3417
|
-
assertKeystorePerms(dir, file);
|
|
4495
|
+
assertKeystorePerms(dir, file, void 0, [localGitGrantPath(dir, inv.relayUrl, bundle.agentName)]);
|
|
3418
4496
|
const minted = await mintAccessToken(relayHttpUrl, stored.agentKey);
|
|
3419
4497
|
persistOrNeedsHuman(dir, { ...stored, agentKey: minted.agentKey });
|
|
3420
4498
|
const name = sanitizeForTty(bundle.agentName);
|
|
@@ -3489,13 +4567,13 @@ function mergeFirstRunPatch(existing, patch) {
|
|
|
3489
4567
|
}
|
|
3490
4568
|
function promptYesNoDefaultYes(question, input, output) {
|
|
3491
4569
|
const rl = createInterface({ input, output });
|
|
3492
|
-
return new Promise((
|
|
4570
|
+
return new Promise((resolve2) => {
|
|
3493
4571
|
let resolved = false;
|
|
3494
4572
|
const done = (result) => {
|
|
3495
4573
|
if (resolved) return;
|
|
3496
4574
|
resolved = true;
|
|
3497
4575
|
rl.close();
|
|
3498
|
-
|
|
4576
|
+
resolve2(result);
|
|
3499
4577
|
};
|
|
3500
4578
|
output.write(question);
|
|
3501
4579
|
rl.on("line", (line) => {
|
|
@@ -3984,20 +5062,20 @@ async function runHandoff(opts) {
|
|
|
3984
5062
|
);
|
|
3985
5063
|
await session.handoffPause();
|
|
3986
5064
|
try {
|
|
3987
|
-
await new Promise((
|
|
5065
|
+
await new Promise((resolve2) => {
|
|
3988
5066
|
const child = spawnFn(scoped.command, scoped.args, {
|
|
3989
5067
|
stdio: "inherit",
|
|
3990
5068
|
cwd: scoped.cwd,
|
|
3991
5069
|
env: buildAcpEnv(process.env, scoped.env ?? void 0)
|
|
3992
5070
|
});
|
|
3993
|
-
child.on("exit", () =>
|
|
5071
|
+
child.on("exit", () => resolve2());
|
|
3994
5072
|
child.on("error", (e) => {
|
|
3995
5073
|
out.write(
|
|
3996
5074
|
`
|
|
3997
5075
|
[handoff] failed to open the session: ${sanitizeForTty(String(e?.message ?? e))}
|
|
3998
5076
|
`
|
|
3999
5077
|
);
|
|
4000
|
-
|
|
5078
|
+
resolve2();
|
|
4001
5079
|
});
|
|
4002
5080
|
});
|
|
4003
5081
|
} finally {
|
|
@@ -4107,12 +5185,12 @@ async function runTakeover(lc, ctx) {
|
|
|
4107
5185
|
`);
|
|
4108
5186
|
return 1;
|
|
4109
5187
|
}
|
|
4110
|
-
const outcome = await new Promise((
|
|
5188
|
+
const outcome = await new Promise((resolve2) => {
|
|
4111
5189
|
let settled = false;
|
|
4112
5190
|
const settle = (o) => {
|
|
4113
5191
|
if (!settled) {
|
|
4114
5192
|
settled = true;
|
|
4115
|
-
|
|
5193
|
+
resolve2(o);
|
|
4116
5194
|
}
|
|
4117
5195
|
};
|
|
4118
5196
|
try {
|
|
@@ -4300,15 +5378,15 @@ function lineClient(sock) {
|
|
|
4300
5378
|
},
|
|
4301
5379
|
drained(timeoutMs) {
|
|
4302
5380
|
if (closed) return Promise.resolve();
|
|
4303
|
-
return new Promise((
|
|
5381
|
+
return new Promise((resolve2) => {
|
|
4304
5382
|
const t = setTimeout(() => {
|
|
4305
5383
|
onClose = null;
|
|
4306
|
-
|
|
5384
|
+
resolve2();
|
|
4307
5385
|
}, timeoutMs);
|
|
4308
5386
|
if (typeof t.unref === "function") t.unref();
|
|
4309
5387
|
onClose = () => {
|
|
4310
5388
|
clearTimeout(t);
|
|
4311
|
-
|
|
5389
|
+
resolve2();
|
|
4312
5390
|
};
|
|
4313
5391
|
});
|
|
4314
5392
|
},
|
|
@@ -4434,13 +5512,13 @@ async function maybeOfferSandboxInstall(deps) {
|
|
|
4434
5512
|
}
|
|
4435
5513
|
function readlinePromptYesNo(input, output) {
|
|
4436
5514
|
return import("readline").then(
|
|
4437
|
-
({ createInterface: createInterface3 }) => new Promise((
|
|
5515
|
+
({ createInterface: createInterface3 }) => new Promise((resolve2) => {
|
|
4438
5516
|
const rl = createInterface3({ input, output });
|
|
4439
5517
|
rl.once("line", (line) => {
|
|
4440
|
-
|
|
5518
|
+
resolve2(line.trim().toLowerCase() === "y");
|
|
4441
5519
|
rl.close();
|
|
4442
5520
|
});
|
|
4443
|
-
rl.once("close", () =>
|
|
5521
|
+
rl.once("close", () => resolve2(false));
|
|
4444
5522
|
})
|
|
4445
5523
|
);
|
|
4446
5524
|
}
|
|
@@ -4662,6 +5740,10 @@ async function applyEndpointGate(cfg, deps, gate, _agentPins, scopeOff = cfg.sco
|
|
|
4662
5740
|
|
|
4663
5741
|
// src/statusVerb.ts
|
|
4664
5742
|
import { homedir as homedir4 } from "os";
|
|
5743
|
+
function gitPosture(e, input) {
|
|
5744
|
+
const g = input.localGitGrant?.(e.agent) ?? null;
|
|
5745
|
+
return g ? { repo: `${g.repoOwner}/${g.repoName}`, mode: g.mode, source: "pat" } : null;
|
|
5746
|
+
}
|
|
4665
5747
|
function endpointRows(e, k, input) {
|
|
4666
5748
|
const derive = input.derive ?? deriveEndpoints;
|
|
4667
5749
|
const derived = derive(k.provider.value, input.env, input.readTextFile);
|
|
@@ -4735,6 +5817,8 @@ function renderStatusHuman(input) {
|
|
|
4735
5817
|
);
|
|
4736
5818
|
const ipcProfile = resolveIpcProfile(e.agent.scope, void 0);
|
|
4737
5819
|
lines.push(` ipcprofile: ${ipcProfile} (${e.agent.scope?.ipcProfile !== void 0 ? "file" : "default"})`);
|
|
5820
|
+
const git = gitPosture(e, input);
|
|
5821
|
+
lines.push(` git : ${git ? `${sanitizeForTty(git.repo)} (${git.mode}, ${git.source})` : "none"}`);
|
|
4738
5822
|
if (e.agent.workspace) lines.push(` workspace: ${sanitizeForTty(e.agent.workspace)}`);
|
|
4739
5823
|
if (e.agent.channelWorkspaces && e.agent.channelWorkspaces.length > 1) {
|
|
4740
5824
|
lines.push(` channels in: ${e.agent.channelWorkspaces.map((w) => sanitizeForTty(w)).join(", ")}`);
|
|
@@ -4771,6 +5855,7 @@ function renderStatusJson(input) {
|
|
|
4771
5855
|
}
|
|
4772
5856
|
},
|
|
4773
5857
|
ipcProfile: { value: ipcProfile, origin: e.agent.scope?.ipcProfile !== void 0 ? "file" : "default" },
|
|
5858
|
+
git: gitPosture(e, input),
|
|
4774
5859
|
endpoints: endpointRows(e, k, input),
|
|
4775
5860
|
// Task 7.1 (D4): null (not "personal"/omitted key) when never cached, so machine
|
|
4776
5861
|
// consumers can distinguish "no data yet" from an actual value.
|
|
@@ -4782,8 +5867,8 @@ function renderStatusJson(input) {
|
|
|
4782
5867
|
}
|
|
4783
5868
|
|
|
4784
5869
|
// src/telemetry.ts
|
|
4785
|
-
import { readFileSync as
|
|
4786
|
-
import { dirname as
|
|
5870
|
+
import { readFileSync as readFileSync9, writeFileSync as writeFileSync7, mkdirSync as mkdirSync7, renameSync as renameSync4, chmodSync as chmodSync6 } from "fs";
|
|
5871
|
+
import { dirname as dirname7, join as join10 } from "path";
|
|
4787
5872
|
var EVENT_PROP_ALLOWLIST = {
|
|
4788
5873
|
tool_mode_selected: ["mode", "surface", "persisted"],
|
|
4789
5874
|
tool_call_blocked: ["tool", "mode", "path", "reason"],
|
|
@@ -4803,11 +5888,11 @@ function isDoNotTrackSet(env) {
|
|
|
4803
5888
|
return /^(1|true|yes)$/i.test(v.trim());
|
|
4804
5889
|
}
|
|
4805
5890
|
function telemetryConfigPath(dir) {
|
|
4806
|
-
return
|
|
5891
|
+
return join10(dir, "telemetry.json");
|
|
4807
5892
|
}
|
|
4808
5893
|
function readTelemetryConfig(dir) {
|
|
4809
5894
|
try {
|
|
4810
|
-
const raw =
|
|
5895
|
+
const raw = readFileSync9(telemetryConfigPath(dir), "utf8");
|
|
4811
5896
|
const parsed = JSON.parse(raw);
|
|
4812
5897
|
if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
4813
5898
|
return parsed;
|
|
@@ -4819,12 +5904,12 @@ function readTelemetryConfig(dir) {
|
|
|
4819
5904
|
}
|
|
4820
5905
|
function writeTelemetryConfig(dir, cfg) {
|
|
4821
5906
|
const file = telemetryConfigPath(dir);
|
|
4822
|
-
|
|
5907
|
+
mkdirSync7(dirname7(file), { recursive: true, mode: 448 });
|
|
4823
5908
|
const tmp = `${file}.tmp-${process.pid}`;
|
|
4824
|
-
|
|
4825
|
-
|
|
5909
|
+
writeFileSync7(tmp, JSON.stringify(cfg, null, 2) + "\n", { mode: 384 });
|
|
5910
|
+
renameSync4(tmp, file);
|
|
4826
5911
|
try {
|
|
4827
|
-
|
|
5912
|
+
chmodSync6(file, 384);
|
|
4828
5913
|
} catch {
|
|
4829
5914
|
}
|
|
4830
5915
|
}
|
|
@@ -4903,12 +5988,12 @@ function renderTelemetryStatusLines(view) {
|
|
|
4903
5988
|
}
|
|
4904
5989
|
|
|
4905
5990
|
// src/service/cli.ts
|
|
4906
|
-
import { execFile } from "child_process";
|
|
5991
|
+
import { execFile as execFile2 } from "child_process";
|
|
4907
5992
|
import { homedir as homedir5 } from "os";
|
|
4908
5993
|
|
|
4909
5994
|
// src/service/manager.ts
|
|
4910
|
-
import { mkdirSync as
|
|
4911
|
-
import { join as
|
|
5995
|
+
import { mkdirSync as mkdirSync9, writeFileSync as writeFileSync9, rmSync as rmSync2, existsSync as existsSync8 } from "fs";
|
|
5996
|
+
import { join as join12 } from "path";
|
|
4912
5997
|
|
|
4913
5998
|
// src/service/templates.ts
|
|
4914
5999
|
function escapeXml(s) {
|
|
@@ -5079,18 +6164,18 @@ function schtasksXml(p) {
|
|
|
5079
6164
|
}
|
|
5080
6165
|
|
|
5081
6166
|
// src/service/win32.ts
|
|
5082
|
-
import { mkdirSync as
|
|
5083
|
-
import { join as
|
|
6167
|
+
import { mkdirSync as mkdirSync8, writeFileSync as writeFileSync8, readdirSync as readdirSync4, readFileSync as readFileSync10, unlinkSync as unlinkSync5 } from "fs";
|
|
6168
|
+
import { join as join11 } from "path";
|
|
5084
6169
|
function win32LogDir(homeDir) {
|
|
5085
|
-
return
|
|
6170
|
+
return join11(homeDir, "AppData", "Local", "braid", "logs");
|
|
5086
6171
|
}
|
|
5087
6172
|
function win32Manager(ctx) {
|
|
5088
|
-
const serviceDir =
|
|
6173
|
+
const serviceDir = join11(ctx.braidDir, "service");
|
|
5089
6174
|
const task = (name) => schtasksLabel(name);
|
|
5090
6175
|
const legacyTask = (name) => legacySchtasksLabel(name);
|
|
5091
|
-
const xmlPath = (name) =>
|
|
5092
|
-
const wrapperPath = (name) =>
|
|
5093
|
-
const launcherPath = (name) =>
|
|
6176
|
+
const xmlPath = (name) => join11(serviceDir, `${task(name)}.xml`);
|
|
6177
|
+
const wrapperPath = (name) => join11(serviceDir, `${task(name)}.cmd`);
|
|
6178
|
+
const launcherPath = (name) => join11(serviceDir, `${task(name)}.vbs`);
|
|
5094
6179
|
const st = (...args) => ctx.exec("schtasks", args);
|
|
5095
6180
|
const stx = async (...args) => {
|
|
5096
6181
|
const res = await st(...args);
|
|
@@ -5101,18 +6186,18 @@ function win32Manager(ctx) {
|
|
|
5101
6186
|
};
|
|
5102
6187
|
const killBridgeProcess = async (name) => {
|
|
5103
6188
|
const legacyAgents = legacyTwinPath(ctx.braidDir, ["agents"]);
|
|
5104
|
-
const agentDirs = [
|
|
6189
|
+
const agentDirs = [join11(ctx.braidDir, "agents"), ...legacyAgents ? [legacyAgents] : []];
|
|
5105
6190
|
const hostDirs = [];
|
|
5106
6191
|
for (const agentsDir of agentDirs) {
|
|
5107
6192
|
try {
|
|
5108
|
-
for (const host of readdirSync4(agentsDir)) hostDirs.push(
|
|
6193
|
+
for (const host of readdirSync4(agentsDir)) hostDirs.push(join11(agentsDir, host));
|
|
5109
6194
|
} catch {
|
|
5110
6195
|
}
|
|
5111
6196
|
}
|
|
5112
6197
|
for (const hostDir of hostDirs) {
|
|
5113
6198
|
let pid = NaN;
|
|
5114
6199
|
try {
|
|
5115
|
-
pid = Number(
|
|
6200
|
+
pid = Number(readFileSync10(join11(hostDir, `${name}.json.lock`), "utf8").trim());
|
|
5116
6201
|
} catch {
|
|
5117
6202
|
continue;
|
|
5118
6203
|
}
|
|
@@ -5128,15 +6213,15 @@ function win32Manager(ctx) {
|
|
|
5128
6213
|
}
|
|
5129
6214
|
};
|
|
5130
6215
|
const write = (name) => {
|
|
5131
|
-
|
|
6216
|
+
mkdirSync8(serviceDir, { recursive: true });
|
|
5132
6217
|
const entryPath = pinnedEntryPath({ braidDir: ctx.braidDir, platform: "win32" });
|
|
5133
6218
|
const cmd = schtasksWrapperCmd({ name, nodePath: ctx.nodePath, entryPath });
|
|
5134
|
-
|
|
6219
|
+
writeFileSync8(wrapperPath(name), cmd);
|
|
5135
6220
|
const vbs = schtasksLauncherVbs({ wrapperPath: wrapperPath(name) });
|
|
5136
|
-
|
|
6221
|
+
writeFileSync8(launcherPath(name), vbs);
|
|
5137
6222
|
const xml = schtasksXml({ name, launcherPath: launcherPath(name) });
|
|
5138
6223
|
const path2 = xmlPath(name);
|
|
5139
|
-
|
|
6224
|
+
writeFileSync8(path2, Buffer.concat([Buffer.from([255, 254]), Buffer.from(xml, "utf16le")]));
|
|
5140
6225
|
return path2;
|
|
5141
6226
|
};
|
|
5142
6227
|
return {
|
|
@@ -5156,13 +6241,13 @@ function win32Manager(ctx) {
|
|
|
5156
6241
|
await st("/delete", "/tn", task(name), "/f");
|
|
5157
6242
|
await st("/delete", "/tn", legacyTask(name), "/f");
|
|
5158
6243
|
try {
|
|
5159
|
-
|
|
6244
|
+
unlinkSync5(join11(runDir(ctx.braidDir), `${name}.token`));
|
|
5160
6245
|
} catch {
|
|
5161
6246
|
}
|
|
5162
6247
|
const legacyToken = legacyTwinPath(ctx.braidDir, ["run", `${name}.token`]);
|
|
5163
6248
|
if (legacyToken) {
|
|
5164
6249
|
try {
|
|
5165
|
-
|
|
6250
|
+
unlinkSync5(legacyToken);
|
|
5166
6251
|
} catch {
|
|
5167
6252
|
}
|
|
5168
6253
|
}
|
|
@@ -5194,7 +6279,7 @@ function win32Manager(ctx) {
|
|
|
5194
6279
|
return { installed, loaded, running };
|
|
5195
6280
|
},
|
|
5196
6281
|
logsPath(name) {
|
|
5197
|
-
return
|
|
6282
|
+
return join11(win32LogDir(ctx.homeDir), `${name}.err.log`);
|
|
5198
6283
|
}
|
|
5199
6284
|
};
|
|
5200
6285
|
}
|
|
@@ -5208,7 +6293,7 @@ var NotSupportedError = class extends Error {
|
|
|
5208
6293
|
};
|
|
5209
6294
|
function makeServiceManager(platform, exec, deps = {}) {
|
|
5210
6295
|
const homeDir = deps.homeDir ?? process.env.HOME ?? "";
|
|
5211
|
-
const braidDir = deps.braidDir ??
|
|
6296
|
+
const braidDir = deps.braidDir ?? join12(homeDir, ".braid");
|
|
5212
6297
|
const uid = deps.uid ?? (typeof process.getuid === "function" ? process.getuid() : 0);
|
|
5213
6298
|
const nodePath = deps.nodePath ?? process.execPath;
|
|
5214
6299
|
const entryPath = pinnedEntryPath({ braidDir });
|
|
@@ -5218,33 +6303,33 @@ function makeServiceManager(platform, exec, deps = {}) {
|
|
|
5218
6303
|
return notSupportedManager(platform);
|
|
5219
6304
|
}
|
|
5220
6305
|
function notSupportedManager(platform) {
|
|
5221
|
-
const
|
|
6306
|
+
const fail2 = () => {
|
|
5222
6307
|
throw new NotSupportedError(`service management is not supported on platform "${platform}"`);
|
|
5223
6308
|
};
|
|
5224
6309
|
return {
|
|
5225
|
-
install: async () =>
|
|
5226
|
-
uninstall: async () =>
|
|
5227
|
-
start: async () =>
|
|
5228
|
-
stop: async () =>
|
|
5229
|
-
restart: async () =>
|
|
5230
|
-
status: async () =>
|
|
5231
|
-
logsPath: () =>
|
|
5232
|
-
templatePath: () =>
|
|
6310
|
+
install: async () => fail2(),
|
|
6311
|
+
uninstall: async () => fail2(),
|
|
6312
|
+
start: async () => fail2(),
|
|
6313
|
+
stop: async () => fail2(),
|
|
6314
|
+
restart: async () => fail2(),
|
|
6315
|
+
status: async () => fail2(),
|
|
6316
|
+
logsPath: () => fail2(),
|
|
6317
|
+
templatePath: () => fail2()
|
|
5233
6318
|
};
|
|
5234
6319
|
}
|
|
5235
6320
|
function darwinManager(ctx) {
|
|
5236
|
-
const launchAgentsDir =
|
|
5237
|
-
const logDir =
|
|
5238
|
-
const legacyLogDir =
|
|
5239
|
-
const plistPath = (name) =>
|
|
5240
|
-
const legacyPlistPath = (name) =>
|
|
5241
|
-
const legacyInstalled = (name) => !
|
|
6321
|
+
const launchAgentsDir = join12(ctx.homeDir, "Library", "LaunchAgents");
|
|
6322
|
+
const logDir = join12(ctx.homeDir, "Library", "Logs", "braid");
|
|
6323
|
+
const legacyLogDir = join12(ctx.homeDir, "Library", "Logs", "arp-bridge");
|
|
6324
|
+
const plistPath = (name) => join12(launchAgentsDir, `${launchdLabel(name)}.plist`);
|
|
6325
|
+
const legacyPlistPath = (name) => join12(launchAgentsDir, `${legacyLaunchdLabel(name)}.plist`);
|
|
6326
|
+
const legacyInstalled = (name) => !existsSync8(plistPath(name)) && existsSync8(legacyPlistPath(name));
|
|
5242
6327
|
const domainTarget = `gui/${ctx.uid}`;
|
|
5243
6328
|
const serviceTarget = (name) => `${domainTarget}/${legacyInstalled(name) ? legacyLaunchdLabel(name) : launchdLabel(name)}`;
|
|
5244
6329
|
const write = (name) => {
|
|
5245
|
-
|
|
5246
|
-
|
|
5247
|
-
|
|
6330
|
+
mkdirSync9(launchAgentsDir, { recursive: true });
|
|
6331
|
+
mkdirSync9(logDir, { recursive: true });
|
|
6332
|
+
writeFileSync9(
|
|
5248
6333
|
plistPath(name),
|
|
5249
6334
|
launchdPlist({ name, nodePath: ctx.nodePath, entryPath: ctx.entryPath, logDir }),
|
|
5250
6335
|
{ mode: 420 }
|
|
@@ -5275,7 +6360,7 @@ function darwinManager(ctx) {
|
|
|
5275
6360
|
}
|
|
5276
6361
|
},
|
|
5277
6362
|
async start(name) {
|
|
5278
|
-
if (!
|
|
6363
|
+
if (!existsSync8(plistPath(name)) && !existsSync8(legacyPlistPath(name))) write(name);
|
|
5279
6364
|
await ctx.exec("launchctl", [
|
|
5280
6365
|
"bootstrap",
|
|
5281
6366
|
domainTarget,
|
|
@@ -5289,7 +6374,7 @@ function darwinManager(ctx) {
|
|
|
5289
6374
|
await ctx.exec("launchctl", ["kickstart", "-k", serviceTarget(name)]);
|
|
5290
6375
|
},
|
|
5291
6376
|
async status(name) {
|
|
5292
|
-
const installed =
|
|
6377
|
+
const installed = existsSync8(plistPath(name)) || existsSync8(legacyPlistPath(name));
|
|
5293
6378
|
if (!installed) return { installed: false, loaded: false, running: false };
|
|
5294
6379
|
const res = await ctx.exec("launchctl", ["print", serviceTarget(name)]);
|
|
5295
6380
|
const loaded = res.code === 0;
|
|
@@ -5298,27 +6383,27 @@ function darwinManager(ctx) {
|
|
|
5298
6383
|
installed,
|
|
5299
6384
|
loaded,
|
|
5300
6385
|
running,
|
|
5301
|
-
staleNodePath: !
|
|
6386
|
+
staleNodePath: !existsSync8(ctx.nodePath)
|
|
5302
6387
|
};
|
|
5303
6388
|
},
|
|
5304
6389
|
logsPath(name) {
|
|
5305
|
-
return
|
|
6390
|
+
return join12(legacyInstalled(name) ? legacyLogDir : logDir, `${name}.err.log`);
|
|
5306
6391
|
}
|
|
5307
6392
|
};
|
|
5308
6393
|
}
|
|
5309
6394
|
function linuxManager(ctx) {
|
|
5310
|
-
const unitDir =
|
|
6395
|
+
const unitDir = join12(ctx.homeDir, ".config", "systemd", "user");
|
|
5311
6396
|
const newUnitName = (name) => `braid-${name}.service`;
|
|
5312
6397
|
const legacyUnitName = (name) => `arp-bridge-${name}.service`;
|
|
5313
|
-
const newUnitPath = (name) =>
|
|
5314
|
-
const legacyUnitPath = (name) =>
|
|
5315
|
-
const legacyInstalled = (name) => !
|
|
6398
|
+
const newUnitPath = (name) => join12(unitDir, newUnitName(name));
|
|
6399
|
+
const legacyUnitPath = (name) => join12(unitDir, legacyUnitName(name));
|
|
6400
|
+
const legacyInstalled = (name) => !existsSync8(newUnitPath(name)) && existsSync8(legacyUnitPath(name));
|
|
5316
6401
|
const unitName = (name) => legacyInstalled(name) ? legacyUnitName(name) : newUnitName(name);
|
|
5317
6402
|
const unitPath = (name) => legacyInstalled(name) ? legacyUnitPath(name) : newUnitPath(name);
|
|
5318
6403
|
const sc = (...args) => ctx.exec("systemctl", ["--user", ...args]);
|
|
5319
6404
|
const write = (name) => {
|
|
5320
|
-
|
|
5321
|
-
|
|
6405
|
+
mkdirSync9(unitDir, { recursive: true });
|
|
6406
|
+
writeFileSync9(
|
|
5322
6407
|
newUnitPath(name),
|
|
5323
6408
|
systemdUnit({ name, nodePath: ctx.nodePath, entryPath: ctx.entryPath }),
|
|
5324
6409
|
{ mode: 420 }
|
|
@@ -5329,7 +6414,7 @@ function linuxManager(ctx) {
|
|
|
5329
6414
|
async install(name) {
|
|
5330
6415
|
write(name);
|
|
5331
6416
|
await sc("daemon-reload");
|
|
5332
|
-
if (
|
|
6417
|
+
if (existsSync8(legacyUnitPath(name))) {
|
|
5333
6418
|
await sc("disable", "--now", legacyUnitName(name));
|
|
5334
6419
|
try {
|
|
5335
6420
|
rmSync2(legacyUnitPath(name), { force: true });
|
|
@@ -5341,7 +6426,7 @@ function linuxManager(ctx) {
|
|
|
5341
6426
|
},
|
|
5342
6427
|
async uninstall(name) {
|
|
5343
6428
|
await sc("disable", "--now", newUnitName(name));
|
|
5344
|
-
if (
|
|
6429
|
+
if (existsSync8(legacyUnitPath(name))) {
|
|
5345
6430
|
await sc("disable", "--now", legacyUnitName(name));
|
|
5346
6431
|
}
|
|
5347
6432
|
try {
|
|
@@ -5355,7 +6440,7 @@ function linuxManager(ctx) {
|
|
|
5355
6440
|
await sc("daemon-reload");
|
|
5356
6441
|
},
|
|
5357
6442
|
async start(name) {
|
|
5358
|
-
if (!
|
|
6443
|
+
if (!existsSync8(unitPath(name))) {
|
|
5359
6444
|
write(name);
|
|
5360
6445
|
await sc("daemon-reload");
|
|
5361
6446
|
}
|
|
@@ -5368,7 +6453,7 @@ function linuxManager(ctx) {
|
|
|
5368
6453
|
await sc("restart", unitName(name));
|
|
5369
6454
|
},
|
|
5370
6455
|
async status(name) {
|
|
5371
|
-
const installed =
|
|
6456
|
+
const installed = existsSync8(unitPath(name));
|
|
5372
6457
|
if (!installed) return { installed: false, loaded: false, running: false };
|
|
5373
6458
|
const active = await sc("is-active", unitName(name));
|
|
5374
6459
|
const enabled = await sc("is-enabled", unitName(name));
|
|
@@ -5378,7 +6463,7 @@ function linuxManager(ctx) {
|
|
|
5378
6463
|
installed,
|
|
5379
6464
|
loaded,
|
|
5380
6465
|
running,
|
|
5381
|
-
staleNodePath: !
|
|
6466
|
+
staleNodePath: !existsSync8(ctx.nodePath)
|
|
5382
6467
|
};
|
|
5383
6468
|
},
|
|
5384
6469
|
logsPath(name) {
|
|
@@ -5388,10 +6473,10 @@ function linuxManager(ctx) {
|
|
|
5388
6473
|
}
|
|
5389
6474
|
|
|
5390
6475
|
// src/service/cli.ts
|
|
5391
|
-
var realExec = (cmd, args) => new Promise((
|
|
5392
|
-
|
|
6476
|
+
var realExec = (cmd, args) => new Promise((resolve2) => {
|
|
6477
|
+
execFile2(cmd, args, { encoding: "utf8" }, (err, stdout, stderr) => {
|
|
5393
6478
|
const code = err && typeof err.code === "number" ? err.code : err ? 1 : 0;
|
|
5394
|
-
|
|
6479
|
+
resolve2({ code, stdout: stdout ?? "", stderr: stderr ?? "" });
|
|
5395
6480
|
});
|
|
5396
6481
|
});
|
|
5397
6482
|
function resolveAgentName(explicit) {
|
|
@@ -5592,17 +6677,17 @@ function printStatusHuman(name, st, logsPath) {
|
|
|
5592
6677
|
}
|
|
5593
6678
|
|
|
5594
6679
|
// src/service/serviceLog.ts
|
|
5595
|
-
import { createWriteStream, mkdirSync as
|
|
5596
|
-
import { dirname as
|
|
6680
|
+
import { createWriteStream, mkdirSync as mkdirSync10 } from "fs";
|
|
6681
|
+
import { dirname as dirname8, join as join13 } from "path";
|
|
5597
6682
|
|
|
5598
6683
|
// src/service/logRotation.ts
|
|
5599
6684
|
import {
|
|
5600
|
-
existsSync as
|
|
5601
|
-
statSync as
|
|
5602
|
-
renameSync as
|
|
6685
|
+
existsSync as existsSync9,
|
|
6686
|
+
statSync as statSync3,
|
|
6687
|
+
renameSync as renameSync5,
|
|
5603
6688
|
rmSync as rmSync3,
|
|
5604
|
-
readFileSync as
|
|
5605
|
-
writeFileSync as
|
|
6689
|
+
readFileSync as readFileSync11,
|
|
6690
|
+
writeFileSync as writeFileSync10
|
|
5606
6691
|
} from "fs";
|
|
5607
6692
|
import { gzipSync } from "zlib";
|
|
5608
6693
|
var DEFAULT_MAX_BYTES = 10 * 1024 * 1024;
|
|
@@ -5611,33 +6696,33 @@ function rotateLogFileIfNeeded(logPath, opts = {}) {
|
|
|
5611
6696
|
const maxBytes = opts.maxBytes ?? DEFAULT_MAX_BYTES;
|
|
5612
6697
|
const keepCount = opts.keepCount ?? DEFAULT_KEEP_COUNT;
|
|
5613
6698
|
try {
|
|
5614
|
-
if (!
|
|
6699
|
+
if (!existsSync9(logPath)) return;
|
|
5615
6700
|
let size;
|
|
5616
6701
|
try {
|
|
5617
|
-
size =
|
|
6702
|
+
size = statSync3(logPath).size;
|
|
5618
6703
|
} catch {
|
|
5619
6704
|
return;
|
|
5620
6705
|
}
|
|
5621
6706
|
if (size <= maxBytes) return;
|
|
5622
6707
|
const gz = (n) => `${logPath}.${n}.gz`;
|
|
5623
6708
|
try {
|
|
5624
|
-
if (
|
|
6709
|
+
if (existsSync9(gz(keepCount))) rmSync3(gz(keepCount), { force: true });
|
|
5625
6710
|
} catch {
|
|
5626
6711
|
}
|
|
5627
6712
|
for (let n = keepCount - 1; n >= 1; n--) {
|
|
5628
6713
|
try {
|
|
5629
|
-
if (
|
|
6714
|
+
if (existsSync9(gz(n))) renameSync5(gz(n), gz(n + 1));
|
|
5630
6715
|
} catch {
|
|
5631
6716
|
}
|
|
5632
6717
|
}
|
|
5633
6718
|
try {
|
|
5634
|
-
const raw =
|
|
5635
|
-
|
|
6719
|
+
const raw = readFileSync11(logPath);
|
|
6720
|
+
writeFileSync10(gz(1), gzipSync(raw));
|
|
5636
6721
|
} catch {
|
|
5637
6722
|
return;
|
|
5638
6723
|
}
|
|
5639
6724
|
try {
|
|
5640
|
-
|
|
6725
|
+
writeFileSync10(logPath, "");
|
|
5641
6726
|
} catch {
|
|
5642
6727
|
}
|
|
5643
6728
|
} catch {
|
|
@@ -5646,16 +6731,16 @@ function rotateLogFileIfNeeded(logPath, opts = {}) {
|
|
|
5646
6731
|
|
|
5647
6732
|
// src/service/serviceLog.ts
|
|
5648
6733
|
function darwinLogDir(homeDir) {
|
|
5649
|
-
return
|
|
6734
|
+
return join13(homeDir, "Library", "Logs", "braid");
|
|
5650
6735
|
}
|
|
5651
6736
|
function serviceLogTargets(name, platform, homeDir) {
|
|
5652
6737
|
if (platform === "darwin") {
|
|
5653
6738
|
const d = darwinLogDir(homeDir);
|
|
5654
|
-
return [
|
|
6739
|
+
return [join13(d, `${name}.out.log`), join13(d, `${name}.err.log`)];
|
|
5655
6740
|
}
|
|
5656
6741
|
if (platform === "win32") {
|
|
5657
6742
|
const d = win32LogDir(homeDir);
|
|
5658
|
-
return [
|
|
6743
|
+
return [join13(d, `${name}.out.log`), join13(d, `${name}.err.log`)];
|
|
5659
6744
|
}
|
|
5660
6745
|
return [];
|
|
5661
6746
|
}
|
|
@@ -5705,7 +6790,7 @@ function initServiceLogging(opts) {
|
|
|
5705
6790
|
};
|
|
5706
6791
|
}
|
|
5707
6792
|
function defaultTeeStream(path2) {
|
|
5708
|
-
|
|
6793
|
+
mkdirSync10(dirname8(path2), { recursive: true });
|
|
5709
6794
|
const stream = createWriteStream(path2, { flags: "a" });
|
|
5710
6795
|
stream.on("error", () => {
|
|
5711
6796
|
});
|
|
@@ -5845,12 +6930,13 @@ function printStatus(agentName, json) {
|
|
|
5845
6930
|
}
|
|
5846
6931
|
const readTextFile = (p) => {
|
|
5847
6932
|
try {
|
|
5848
|
-
return
|
|
6933
|
+
return readFileSync12(p, "utf8");
|
|
5849
6934
|
} catch {
|
|
5850
6935
|
return null;
|
|
5851
6936
|
}
|
|
5852
6937
|
};
|
|
5853
|
-
const
|
|
6938
|
+
const localGitGrant = (a) => loadLocalGitGrant(dir, a.relayUrl, a.agentName);
|
|
6939
|
+
const input = { entries, env: process.env, readTextFile, localGitGrant };
|
|
5854
6940
|
if (json) {
|
|
5855
6941
|
console.log(renderStatusJson(input));
|
|
5856
6942
|
return;
|
|
@@ -5913,19 +6999,84 @@ function setIpcProfile(profile, agentName) {
|
|
|
5913
6999
|
);
|
|
5914
7000
|
}
|
|
5915
7001
|
}
|
|
7002
|
+
async function gitAuthSet(inv) {
|
|
7003
|
+
const dir = configDir(process.env);
|
|
7004
|
+
const entry = loadAgent(dir, inv.agent, "Run: braid list").agent;
|
|
7005
|
+
if (process.stdin.isTTY === true) {
|
|
7006
|
+
console.error(
|
|
7007
|
+
"[braid] git-auth set reads the token from stdin and nothing is piped in. Run it as: <command that prints the token> | braid git-auth set <agent> <owner/repo> --mode <mode> --token-stdin"
|
|
7008
|
+
);
|
|
7009
|
+
return 1;
|
|
7010
|
+
}
|
|
7011
|
+
let token;
|
|
7012
|
+
try {
|
|
7013
|
+
token = (await readBoundedStdin(process.stdin, 4096, "token")).trim();
|
|
7014
|
+
} catch (err) {
|
|
7015
|
+
console.error(`[braid] git-auth set failed: ${sanitizeForTty(err instanceof Error ? err.message : String(err))}`);
|
|
7016
|
+
return 1;
|
|
7017
|
+
}
|
|
7018
|
+
if (token === "") {
|
|
7019
|
+
console.error("[braid] git-auth set received an empty token on stdin. Pipe the token in and retry.");
|
|
7020
|
+
return 1;
|
|
7021
|
+
}
|
|
7022
|
+
saveLocalGitGrant(dir, entry.relayUrl, entry.agentName, {
|
|
7023
|
+
source: "pat",
|
|
7024
|
+
repoOwner: inv.repoOwner,
|
|
7025
|
+
repoName: inv.repoName,
|
|
7026
|
+
mode: inv.mode,
|
|
7027
|
+
allowWorkflows: inv.allowWorkflows,
|
|
7028
|
+
allowRiskPaths: inv.allowRiskPaths,
|
|
7029
|
+
allowPublic: inv.allowPublic,
|
|
7030
|
+
token
|
|
7031
|
+
});
|
|
7032
|
+
console.log(
|
|
7033
|
+
`[braid] ${sanitizeForTty(entry.agentName)}: git access set for ${inv.repoOwner}/${inv.repoName} (${inv.mode}, local token). Restart the bridge for the change to take effect.`
|
|
7034
|
+
);
|
|
7035
|
+
return 0;
|
|
7036
|
+
}
|
|
7037
|
+
function gitAuthShow(agentName) {
|
|
7038
|
+
const dir = configDir(process.env);
|
|
7039
|
+
const entry = loadAgent(dir, agentName, "Run: braid list").agent;
|
|
7040
|
+
const name = sanitizeForTty(entry.agentName);
|
|
7041
|
+
const g = loadLocalGitGrant(dir, entry.relayUrl, entry.agentName);
|
|
7042
|
+
if (!g) {
|
|
7043
|
+
console.log(`[braid] ${name}: no local git grant.`);
|
|
7044
|
+
return;
|
|
7045
|
+
}
|
|
7046
|
+
console.log(`[braid] ${name}: local git grant`);
|
|
7047
|
+
console.log(` repo : ${sanitizeForTty(`${g.repoOwner}/${g.repoName}`)}`);
|
|
7048
|
+
console.log(` mode : ${g.mode}`);
|
|
7049
|
+
console.log(` allowWorkflows : ${g.allowWorkflows}`);
|
|
7050
|
+
console.log(` allowRiskPaths : ${g.allowRiskPaths}`);
|
|
7051
|
+
console.log(` allowPublic : ${g.allowPublic}`);
|
|
7052
|
+
console.log(" source: pat");
|
|
7053
|
+
}
|
|
7054
|
+
function gitAuthClear(agentName) {
|
|
7055
|
+
const dir = configDir(process.env);
|
|
7056
|
+
const entry = loadAgent(dir, agentName, "Run: braid list").agent;
|
|
7057
|
+
const name = sanitizeForTty(entry.agentName);
|
|
7058
|
+
if (clearLocalGitGrant(dir, entry.relayUrl, entry.agentName)) {
|
|
7059
|
+
console.log(`[braid] ${name}: local git grant removed. Restart the bridge for the change to take effect.`);
|
|
7060
|
+
} else {
|
|
7061
|
+
console.log(`[braid] ${name}: nothing to clear, no local git grant was set.`);
|
|
7062
|
+
}
|
|
7063
|
+
}
|
|
5916
7064
|
function printScope(agentName) {
|
|
5917
7065
|
const dir = configDir(process.env);
|
|
5918
7066
|
let scope;
|
|
5919
7067
|
let pinnedEndpoints;
|
|
7068
|
+
let resolved;
|
|
5920
7069
|
if (agentName !== void 0) {
|
|
5921
7070
|
const entry = loadAgent(dir, agentName, "Run: braid scope <name>").agent;
|
|
5922
7071
|
scope = entry.scope;
|
|
5923
7072
|
pinnedEndpoints = entry.pinnedEndpoints;
|
|
7073
|
+
resolved = entry;
|
|
5924
7074
|
} else {
|
|
5925
7075
|
try {
|
|
5926
7076
|
const entry = loadAgent(dir).agent;
|
|
5927
7077
|
scope = entry.scope;
|
|
5928
7078
|
pinnedEndpoints = entry.pinnedEndpoints;
|
|
7079
|
+
resolved = entry;
|
|
5929
7080
|
} catch {
|
|
5930
7081
|
}
|
|
5931
7082
|
}
|
|
@@ -5934,9 +7085,15 @@ function printScope(agentName) {
|
|
|
5934
7085
|
for (const line of lines) {
|
|
5935
7086
|
console.log(line);
|
|
5936
7087
|
}
|
|
7088
|
+
if (resolved !== void 0) {
|
|
7089
|
+
const g = loadLocalGitGrant(dir, resolved.relayUrl, resolved.agentName);
|
|
7090
|
+
console.log(
|
|
7091
|
+
g ? `[scope] git access: ${sanitizeForTty(`${g.repoOwner}/${g.repoName}`)} (${g.mode}, pat)` : "[scope] git access: none locally (app grants, if any, are shown by the web app)"
|
|
7092
|
+
);
|
|
7093
|
+
}
|
|
5937
7094
|
const readTextFile = (p) => {
|
|
5938
7095
|
try {
|
|
5939
|
-
return
|
|
7096
|
+
return readFileSync12(p, "utf8");
|
|
5940
7097
|
} catch {
|
|
5941
7098
|
return null;
|
|
5942
7099
|
}
|
|
@@ -5976,7 +7133,7 @@ function scopeAllow(agentName, host) {
|
|
|
5976
7133
|
const { agent: provider } = resolveAgentSelection(process.env);
|
|
5977
7134
|
const readTextFile = (p) => {
|
|
5978
7135
|
try {
|
|
5979
|
-
return
|
|
7136
|
+
return readFileSync12(p, "utf8");
|
|
5980
7137
|
} catch {
|
|
5981
7138
|
return null;
|
|
5982
7139
|
}
|
|
@@ -6091,6 +7248,17 @@ async function main() {
|
|
|
6091
7248
|
scopeAllowCodeHosts(invocation.agent);
|
|
6092
7249
|
return;
|
|
6093
7250
|
}
|
|
7251
|
+
if (invocation.kind === "git-auth-set") {
|
|
7252
|
+
process.exit(await gitAuthSet(invocation));
|
|
7253
|
+
}
|
|
7254
|
+
if (invocation.kind === "git-auth-show") {
|
|
7255
|
+
gitAuthShow(invocation.agent);
|
|
7256
|
+
return;
|
|
7257
|
+
}
|
|
7258
|
+
if (invocation.kind === "git-auth-clear") {
|
|
7259
|
+
gitAuthClear(invocation.agent);
|
|
7260
|
+
return;
|
|
7261
|
+
}
|
|
6094
7262
|
if (invocation.kind === "telemetry") {
|
|
6095
7263
|
runTelemetryVerb(invocation.verb);
|
|
6096
7264
|
return;
|
|
@@ -6241,7 +7409,7 @@ async function main() {
|
|
|
6241
7409
|
} catch {
|
|
6242
7410
|
}
|
|
6243
7411
|
}
|
|
6244
|
-
assertKeystorePerms(dir, file);
|
|
7412
|
+
assertKeystorePerms(dir, file, void 0, cfg.agentName ? [localGitGrantPath(dir, cfg.relayWsUrl, cfg.agentName)] : []);
|
|
6245
7413
|
}
|
|
6246
7414
|
console.log(banner("braid", "STARTING", startingLines(cfg, {
|
|
6247
7415
|
...cfg.agentMode === "acp" && cfg.agent === "claude-code" ? { claudeBinary: describeClaudeBinary(resolveClaudeBinary()) } : {}
|
|
@@ -6262,7 +7430,7 @@ async function main() {
|
|
|
6262
7430
|
const epScopeOff = process.env.BRAID_SCOPE === "off" || cfg.scope?.disabled === true;
|
|
6263
7431
|
const readTextFile = (p) => {
|
|
6264
7432
|
try {
|
|
6265
|
-
return
|
|
7433
|
+
return readFileSync12(p, "utf8");
|
|
6266
7434
|
} catch {
|
|
6267
7435
|
return null;
|
|
6268
7436
|
}
|
|
@@ -6292,7 +7460,7 @@ async function main() {
|
|
|
6292
7460
|
cancelled = true;
|
|
6293
7461
|
rl.close();
|
|
6294
7462
|
});
|
|
6295
|
-
const eofGuard = new Promise((
|
|
7463
|
+
const eofGuard = new Promise((resolve2) => rl.once("close", () => resolve2(false)));
|
|
6296
7464
|
try {
|
|
6297
7465
|
const answer = await Promise.race([
|
|
6298
7466
|
rl.question(prompt).then((a) => a.trim().toLowerCase() === "y"),
|