@snowyroad/braid 0.96.8 → 0.98.0
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 +28 -4
- package/dist/{chunk-QHWKKDPN.js → chunk-3SHZYLWH.js} +205 -8
- package/dist/cli.js +1259 -132
- package/dist/{http-3WYFRJZR.js → http-7QNDIXBN.js} +1 -1
- package/dist/mcp/server.js +120 -2
- package/package.json +1 -1
- package/service-shrinkwrap.json +19 -19
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,
|
|
@@ -75,6 +78,7 @@ import {
|
|
|
75
78
|
sanitizeForTty,
|
|
76
79
|
saveAgent,
|
|
77
80
|
scopedLaunch,
|
|
81
|
+
setScopeAllowCodeHosts,
|
|
78
82
|
setVerboseOutput,
|
|
79
83
|
snapshotHttpMetrics,
|
|
80
84
|
startingLines,
|
|
@@ -82,8 +86,9 @@ import {
|
|
|
82
86
|
toolStatusLine,
|
|
83
87
|
updateAgentWorkspace,
|
|
84
88
|
validateRelayWsUrl,
|
|
89
|
+
withAgentGitEnv,
|
|
85
90
|
wsToHttp
|
|
86
|
-
} from "./chunk-
|
|
91
|
+
} from "./chunk-3SHZYLWH.js";
|
|
87
92
|
import {
|
|
88
93
|
clampUntrusted,
|
|
89
94
|
externalSourceLabel,
|
|
@@ -411,6 +416,10 @@ var ChannelSession = class {
|
|
|
411
416
|
setHumans(entries) {
|
|
412
417
|
this.humans = entries;
|
|
413
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;
|
|
414
423
|
/**
|
|
415
424
|
* Handoff hold (persist-and-catch-up): non-null while the owner is driving the real
|
|
416
425
|
* interactive session. Live messages land HERE instead of the (intentionally stopped)
|
|
@@ -590,6 +599,14 @@ ${fence("reactions", formatReactions(msg.reactions))}
|
|
|
590
599
|
* session or fabricates freshness/usage.
|
|
591
600
|
*/
|
|
592
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) {
|
|
593
610
|
if (!this.flow) {
|
|
594
611
|
throw new Error(
|
|
595
612
|
"[braid] lisa fresh-session turn requested but flow routing is unavailable for this session"
|
|
@@ -904,11 +921,11 @@ async function elicitCard(converse, agentName, opts = {}) {
|
|
|
904
921
|
return buildPartialCard(agentName, { description: opts.fallbackDescription ?? "", skills: [] });
|
|
905
922
|
}
|
|
906
923
|
function withTimeout(p, ms) {
|
|
907
|
-
return new Promise((
|
|
924
|
+
return new Promise((resolve2, reject) => {
|
|
908
925
|
const t = setTimeout(() => reject(new Error("elicit timeout")), ms);
|
|
909
926
|
p.then((v) => {
|
|
910
927
|
clearTimeout(t);
|
|
911
|
-
|
|
928
|
+
resolve2(v);
|
|
912
929
|
}, (e) => {
|
|
913
930
|
clearTimeout(t);
|
|
914
931
|
reject(e);
|
|
@@ -1584,7 +1601,7 @@ function startControlSocket(opts) {
|
|
|
1584
1601
|
handleConnection(conn, opts, token, log2);
|
|
1585
1602
|
});
|
|
1586
1603
|
server.on("error", (err) => log2(`[braid] control socket server error: ${err.message}`));
|
|
1587
|
-
return new Promise((
|
|
1604
|
+
return new Promise((resolve2, reject) => {
|
|
1588
1605
|
server.once("error", reject);
|
|
1589
1606
|
server.listen(opts.sockPath, () => {
|
|
1590
1607
|
server.removeListener("error", reject);
|
|
@@ -1595,7 +1612,7 @@ function startControlSocket(opts) {
|
|
|
1595
1612
|
}
|
|
1596
1613
|
}
|
|
1597
1614
|
log2(`[braid] control socket listening at ${opts.sockPath}`);
|
|
1598
|
-
|
|
1615
|
+
resolve2({
|
|
1599
1616
|
path: opts.sockPath,
|
|
1600
1617
|
close: () => new Promise((res) => {
|
|
1601
1618
|
for (const c of conns) {
|
|
@@ -1626,7 +1643,7 @@ function startControlSocket(opts) {
|
|
|
1626
1643
|
});
|
|
1627
1644
|
}
|
|
1628
1645
|
function requestStatus(sockPath, timeoutMs = 3e3, token) {
|
|
1629
|
-
return new Promise((
|
|
1646
|
+
return new Promise((resolve2, reject) => {
|
|
1630
1647
|
const c = net.createConnection(sockPath);
|
|
1631
1648
|
let buf = "";
|
|
1632
1649
|
let settled = false;
|
|
@@ -1660,7 +1677,7 @@ function requestStatus(sockPath, timeoutMs = 3e3, token) {
|
|
|
1660
1677
|
finish(() => reject(new Error(`control socket status error: ${String(obj.error)}`)));
|
|
1661
1678
|
return;
|
|
1662
1679
|
}
|
|
1663
|
-
finish(() =>
|
|
1680
|
+
finish(() => resolve2(obj));
|
|
1664
1681
|
});
|
|
1665
1682
|
c.on("error", (e) => finish(() => reject(e)));
|
|
1666
1683
|
c.on("close", () => finish(() => reject(new Error("control socket closed before a status line"))));
|
|
@@ -1678,7 +1695,7 @@ function socketLive(sockPath, platform = process.platform, timeoutMs = 1e3) {
|
|
|
1678
1695
|
if (platform !== "win32") {
|
|
1679
1696
|
return Promise.resolve(existsSync2(sockPath));
|
|
1680
1697
|
}
|
|
1681
|
-
return new Promise((
|
|
1698
|
+
return new Promise((resolve2) => {
|
|
1682
1699
|
let settled = false;
|
|
1683
1700
|
const done = (live) => {
|
|
1684
1701
|
if (settled) return;
|
|
@@ -1688,7 +1705,7 @@ function socketLive(sockPath, platform = process.platform, timeoutMs = 1e3) {
|
|
|
1688
1705
|
c.destroy();
|
|
1689
1706
|
} catch {
|
|
1690
1707
|
}
|
|
1691
|
-
|
|
1708
|
+
resolve2(live);
|
|
1692
1709
|
};
|
|
1693
1710
|
const c = net.createConnection(sockPath);
|
|
1694
1711
|
const timer = setTimeout(() => done(false), timeoutMs);
|
|
@@ -1840,7 +1857,7 @@ function startAttachStream(conn, hub, channelId) {
|
|
|
1840
1857
|
function startHandoffConversation(conn, ctl, initialChannelId, writeLine, log2) {
|
|
1841
1858
|
let phase = "selecting";
|
|
1842
1859
|
let channelId = null;
|
|
1843
|
-
const
|
|
1860
|
+
const fail2 = (error) => {
|
|
1844
1861
|
phase = "closed";
|
|
1845
1862
|
writeLine({ error });
|
|
1846
1863
|
conn.end();
|
|
@@ -1848,7 +1865,7 @@ function startHandoffConversation(conn, ctl, initialChannelId, writeLine, log2)
|
|
|
1848
1865
|
const doOffer = (id) => {
|
|
1849
1866
|
const res = ctl.offer(id);
|
|
1850
1867
|
if (!res.ok) {
|
|
1851
|
-
|
|
1868
|
+
fail2(res.error);
|
|
1852
1869
|
return;
|
|
1853
1870
|
}
|
|
1854
1871
|
channelId = id;
|
|
@@ -1887,14 +1904,14 @@ function startHandoffConversation(conn, ctl, initialChannelId, writeLine, log2)
|
|
|
1887
1904
|
return;
|
|
1888
1905
|
}
|
|
1889
1906
|
if (!res.ok) {
|
|
1890
|
-
|
|
1907
|
+
fail2(res.error);
|
|
1891
1908
|
return;
|
|
1892
1909
|
}
|
|
1893
1910
|
phase = "spec_sent";
|
|
1894
1911
|
writeLine({ handoff: "spec", ...res.spec });
|
|
1895
1912
|
}).catch((e) => {
|
|
1896
1913
|
log2(`[braid] handoff begin failed: ${e.message}`);
|
|
1897
|
-
if (phase !== "closed")
|
|
1914
|
+
if (phase !== "closed") fail2("begin_failed");
|
|
1898
1915
|
});
|
|
1899
1916
|
return;
|
|
1900
1917
|
}
|
|
@@ -1913,7 +1930,7 @@ function startHandoffConversation(conn, ctl, initialChannelId, writeLine, log2)
|
|
|
1913
1930
|
});
|
|
1914
1931
|
return;
|
|
1915
1932
|
}
|
|
1916
|
-
|
|
1933
|
+
fail2("protocol_error");
|
|
1917
1934
|
};
|
|
1918
1935
|
}
|
|
1919
1936
|
function startRecoverConversation(conn, ctl, writeLine, log2) {
|
|
@@ -2376,7 +2393,7 @@ function makeSocketHandoffController(deps) {
|
|
|
2376
2393
|
session = c.session;
|
|
2377
2394
|
spec = c.spec;
|
|
2378
2395
|
}
|
|
2379
|
-
const scoped = await scopedLaunchFn({ command: spec.command, args: spec.args, cwd: spec.cwd }, { scope: deps.scope }, deps.provider);
|
|
2396
|
+
const scoped = await scopedLaunchFn({ command: spec.command, args: spec.args, cwd: spec.cwd, env: spec.env }, { scope: deps.scope }, deps.provider);
|
|
2380
2397
|
pauseAttempted = true;
|
|
2381
2398
|
await session.handoffPause();
|
|
2382
2399
|
active = { channelId, session };
|
|
@@ -2446,6 +2463,913 @@ function makeSocketHandoffController(deps) {
|
|
|
2446
2463
|
};
|
|
2447
2464
|
}
|
|
2448
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
|
+
function shellQuote(p) {
|
|
2617
|
+
return `'${p.replace(/'/g, `'\\''`)}'`;
|
|
2618
|
+
}
|
|
2619
|
+
function credentialHelperArgs(credFile) {
|
|
2620
|
+
return ["-c", "credential.helper=", "-c", `credential.helper=store --file=${shellQuote(credFile)}`];
|
|
2621
|
+
}
|
|
2622
|
+
function safeSegment(s) {
|
|
2623
|
+
return s.replace(/[^a-zA-Z0-9._-]/g, "_");
|
|
2624
|
+
}
|
|
2625
|
+
function logSafe(s) {
|
|
2626
|
+
return s.replace(/[^A-Za-z0-9._\/-]/g, "?").slice(0, 200) || "-";
|
|
2627
|
+
}
|
|
2628
|
+
function isAutonomous(g) {
|
|
2629
|
+
return g.mode === "autonomous";
|
|
2630
|
+
}
|
|
2631
|
+
var CONFIG_INCLUDE_RE = /^\s*\[include(If)?\b/im;
|
|
2632
|
+
function readConfig(gitDir) {
|
|
2633
|
+
try {
|
|
2634
|
+
return readFileSync7(join7(gitDir, "config"), "utf8");
|
|
2635
|
+
} catch (e) {
|
|
2636
|
+
if (e.code === "ENOENT") return "";
|
|
2637
|
+
throw e;
|
|
2638
|
+
}
|
|
2639
|
+
}
|
|
2640
|
+
var GitGateway = class {
|
|
2641
|
+
constructor(deps) {
|
|
2642
|
+
this.deps = deps;
|
|
2643
|
+
this.fetchFn = deps.fetchFn ?? ((...a) => fetch(...a));
|
|
2644
|
+
}
|
|
2645
|
+
deps;
|
|
2646
|
+
/** D15: per-channel disable depth; git is enabled only at depth 0 (overlapping lisa turns nest). */
|
|
2647
|
+
disabledDepth = /* @__PURE__ */ new Map();
|
|
2648
|
+
locks = /* @__PURE__ */ new Map();
|
|
2649
|
+
knownDefault = /* @__PURE__ */ new Map();
|
|
2650
|
+
fetchFn;
|
|
2651
|
+
/** D15: lisa fresh turns (and the critics they spawn) must not use the gateway. */
|
|
2652
|
+
setChannelGitEnabled(channelId, enabled) {
|
|
2653
|
+
const depth = Math.max(0, (this.disabledDepth.get(channelId) ?? 0) + (enabled ? -1 : 1));
|
|
2654
|
+
if (depth === 0) this.disabledDepth.delete(channelId);
|
|
2655
|
+
else this.disabledDepth.set(channelId, depth);
|
|
2656
|
+
}
|
|
2657
|
+
status(channelId, args) {
|
|
2658
|
+
return this.guard("status", () => this.statusInner(channelId, args));
|
|
2659
|
+
}
|
|
2660
|
+
fetch(channelId, args) {
|
|
2661
|
+
return this.guard("fetch", () => this.fetchInner(channelId, args));
|
|
2662
|
+
}
|
|
2663
|
+
async push(channelId, args) {
|
|
2664
|
+
const branch = String(args.branch ?? "");
|
|
2665
|
+
const out = {};
|
|
2666
|
+
const r = await this.guard("push", () => this.pushInner(channelId, branch, args, out));
|
|
2667
|
+
this.log(`push ${out.repo ?? "-"} ${logSafe(branch)} ${out.sha ?? "-"} ${r.ok ? "ok" : `refused:${r.code}`}`);
|
|
2668
|
+
return r;
|
|
2669
|
+
}
|
|
2670
|
+
openPr(channelId, args) {
|
|
2671
|
+
return this.guard("pull request", () => this.openPrInner(channelId, args));
|
|
2672
|
+
}
|
|
2673
|
+
mergePr(channelId, args) {
|
|
2674
|
+
return this.guard("merge", () => this.mergePrInner(channelId, args));
|
|
2675
|
+
}
|
|
2676
|
+
// ---- operations ----
|
|
2677
|
+
async statusInner(channelId, args) {
|
|
2678
|
+
const gate = this.gateChecks(channelId);
|
|
2679
|
+
if (gate) return gate;
|
|
2680
|
+
const grant = await this.singleGrant();
|
|
2681
|
+
if (!grant.ok) return grant;
|
|
2682
|
+
const g = grant.grant;
|
|
2683
|
+
const wt = this.worktree(channelId, args.path);
|
|
2684
|
+
if (!wt.ok) return wt;
|
|
2685
|
+
const opts = { gitDir: wt.gitDir, cwd: wt.path };
|
|
2686
|
+
const br = await this.git(["rev-parse", "--abbrev-ref", "HEAD"], opts);
|
|
2687
|
+
const hd = await this.git(["rev-parse", "--verify", "-q", "HEAD"], opts);
|
|
2688
|
+
const branch = br.code === 0 ? br.stdout.trim() : null;
|
|
2689
|
+
const head = hd.code === 0 ? hd.stdout.trim() : null;
|
|
2690
|
+
let ahead = null;
|
|
2691
|
+
let behind = null;
|
|
2692
|
+
let tracking;
|
|
2693
|
+
if (!head || !branch || branch === "HEAD") {
|
|
2694
|
+
tracking = head ? "HEAD is detached, so there is no braid/<branch> to compare with." : "The worktree has no commits yet.";
|
|
2695
|
+
} else {
|
|
2696
|
+
const ref = `refs/remotes/braid/${branch}`;
|
|
2697
|
+
const has = await this.git(["rev-parse", "--verify", "-q", ref], opts);
|
|
2698
|
+
if (has.code !== 0) {
|
|
2699
|
+
tracking = `There is no fetched copy of braid/${branch}; run braid_git_fetch to compare with the remote.`;
|
|
2700
|
+
} else {
|
|
2701
|
+
const c = await this.git(["rev-list", "--left-right", "--count", `HEAD...${ref}`], opts);
|
|
2702
|
+
const m = /^(\d+)\s+(\d+)/.exec(c.stdout.trim());
|
|
2703
|
+
if (c.code === 0 && m) {
|
|
2704
|
+
ahead = Number(m[1]);
|
|
2705
|
+
behind = Number(m[2]);
|
|
2706
|
+
tracking = `Ahead ${ahead}, behind ${behind} of braid/${branch} (as of the last braid_git_fetch).`;
|
|
2707
|
+
} else {
|
|
2708
|
+
tracking = `Could not compare with braid/${branch}; run braid_git_fetch and try again.`;
|
|
2709
|
+
}
|
|
2710
|
+
}
|
|
2711
|
+
}
|
|
2712
|
+
const repo = `${g.repoOwner}/${g.repoName}`;
|
|
2713
|
+
const mode = isAutonomous(g) ? "autonomous" : "pr-only";
|
|
2714
|
+
const lines = [
|
|
2715
|
+
`Git access: ${repo}, mode ${mode}.`,
|
|
2716
|
+
`Rules: ${this.rulesText(g)}`,
|
|
2717
|
+
`Worktree ${wt.path}: branch ${branch ?? "unknown"}, HEAD ${head ?? "none"}.`,
|
|
2718
|
+
tracking
|
|
2719
|
+
];
|
|
2720
|
+
return { ok: true, text: lines.join("\n"), data: { repo, mode, branch, head, ahead, behind } };
|
|
2721
|
+
}
|
|
2722
|
+
async fetchInner(channelId, args) {
|
|
2723
|
+
const gate = this.gateChecks(channelId);
|
|
2724
|
+
if (gate) return gate;
|
|
2725
|
+
const grant = await this.singleGrant();
|
|
2726
|
+
if (!grant.ok) return grant;
|
|
2727
|
+
const g = grant.grant;
|
|
2728
|
+
const mirror = this.mirrorPath(g);
|
|
2729
|
+
return this.serialize(mirror, async () => {
|
|
2730
|
+
const gate2 = this.gateChecks(channelId);
|
|
2731
|
+
if (gate2) return gate2;
|
|
2732
|
+
const m = await this.ensureMirror(mirror);
|
|
2733
|
+
if (m) return m;
|
|
2734
|
+
const cred = await this.credentialAndPolicy(g);
|
|
2735
|
+
if (!cred.ok) return cred;
|
|
2736
|
+
const wt = this.worktree(channelId, args.path);
|
|
2737
|
+
if (!wt.ok) return wt;
|
|
2738
|
+
const id = this.identity(wt);
|
|
2739
|
+
if (!id) return this.worktreeChanged("fetch");
|
|
2740
|
+
const remoteUrl = this.remoteUrl(g);
|
|
2741
|
+
const credFile = join7(mirror, ".braid-cred");
|
|
2742
|
+
try {
|
|
2743
|
+
this.writeCredFile(credFile, cred.value.token);
|
|
2744
|
+
const r = await this.git([...credentialHelperArgs(credFile), "fetch", "--quiet", "--no-tags", "--prune", remoteUrl, "+refs/heads/*:refs/remotes/braid/*"], { gitDir: mirror }, true);
|
|
2745
|
+
if (r.code !== 0) return this.remoteRejected(g, r.stderr);
|
|
2746
|
+
} finally {
|
|
2747
|
+
this.removeCredFile(credFile);
|
|
2748
|
+
}
|
|
2749
|
+
if (!this.sameIdentity(wt, id)) return this.worktreeChanged("fetch");
|
|
2750
|
+
const into = await this.git(["fetch", "--quiet", "--no-tags", "--prune", mirror, "+refs/remotes/braid/*:refs/remotes/braid/*"], { gitDir: wt.gitDir, cwd: wt.path }, true);
|
|
2751
|
+
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)}.`);
|
|
2752
|
+
const list = await this.git(["for-each-ref", "--format=%(refname:short) %(objectname:short=12)", "refs/remotes/braid/"], { gitDir: wt.gitDir, cwd: wt.path });
|
|
2753
|
+
const refs = list.stdout.split("\n").filter(Boolean);
|
|
2754
|
+
const shown = refs.slice(0, 30).join("\n");
|
|
2755
|
+
const more = refs.length > 30 ? `
|
|
2756
|
+
(${refs.length - 30} more)` : "";
|
|
2757
|
+
this.log(`fetch ${g.repoOwner}/${g.repoName} ok`);
|
|
2758
|
+
return {
|
|
2759
|
+
ok: true,
|
|
2760
|
+
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.
|
|
2761
|
+
${shown}${more}`,
|
|
2762
|
+
data: { refs: refs.length }
|
|
2763
|
+
};
|
|
2764
|
+
});
|
|
2765
|
+
}
|
|
2766
|
+
/** D8, step by step. */
|
|
2767
|
+
async pushInner(channelId, branch, args, out) {
|
|
2768
|
+
const gate = this.gateChecks(channelId);
|
|
2769
|
+
if (gate) return gate;
|
|
2770
|
+
const grant = await this.singleGrant();
|
|
2771
|
+
if (!grant.ok) return grant;
|
|
2772
|
+
const listed = grant.grant;
|
|
2773
|
+
const repo = `${listed.repoOwner}/${listed.repoName}`;
|
|
2774
|
+
out.repo = repo;
|
|
2775
|
+
const bad = await this.checkBranch(branch);
|
|
2776
|
+
if (bad) return bad;
|
|
2777
|
+
const ns = this.namespace();
|
|
2778
|
+
if (!isAutonomous(listed) && !branch.startsWith(ns)) {
|
|
2779
|
+
const hint = this.knownDefault.get(repo) ?? await this.worktreeDefaultHint(channelId, args.path);
|
|
2780
|
+
return hint === branch ? this.defaultProtected(branch) : this.outsideNamespace();
|
|
2781
|
+
}
|
|
2782
|
+
const mirror = this.mirrorPath(listed);
|
|
2783
|
+
return this.serialize(mirror, async () => {
|
|
2784
|
+
const gate2 = this.gateChecks(channelId);
|
|
2785
|
+
if (gate2) return gate2;
|
|
2786
|
+
const m = await this.ensureMirror(mirror);
|
|
2787
|
+
if (m) return m;
|
|
2788
|
+
const cred = await this.credentialAndPolicy(listed);
|
|
2789
|
+
if (!cred.ok) return cred;
|
|
2790
|
+
const g = cred.grant;
|
|
2791
|
+
const meta = await this.repoMeta(g, cred.value);
|
|
2792
|
+
if (!meta.ok) return meta;
|
|
2793
|
+
const pub = this.publicCheck(g, meta.value);
|
|
2794
|
+
if (pub) return pub;
|
|
2795
|
+
if (!isAutonomous(g)) {
|
|
2796
|
+
if (meta.value.defaultBranch === branch) return this.defaultProtected(branch);
|
|
2797
|
+
if (!branch.startsWith(ns)) return this.outsideNamespace();
|
|
2798
|
+
}
|
|
2799
|
+
const wt = this.worktree(channelId, args.path);
|
|
2800
|
+
if (!wt.ok) return wt;
|
|
2801
|
+
const id = this.identity(wt);
|
|
2802
|
+
if (!id) return this.worktreeChanged("push", mirror);
|
|
2803
|
+
const sha = await this.resolveSha(wt, args.sha ?? "HEAD");
|
|
2804
|
+
if (!sha.ok) return sha;
|
|
2805
|
+
out.sha = sha.sha;
|
|
2806
|
+
if (!this.sameIdentity(wt, id)) return this.worktreeChanged("push", mirror);
|
|
2807
|
+
const inc = await this.git(["fetch", "--quiet", "--no-tags", wt.gitDir, `+${sha.sha}:refs/braid/incoming`], { gitDir: mirror }, true);
|
|
2808
|
+
if (!this.sameIdentity(wt, id)) return this.worktreeChanged("push", mirror);
|
|
2809
|
+
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)}.`);
|
|
2810
|
+
const remoteUrl = this.remoteUrl(g);
|
|
2811
|
+
const credFile = join7(mirror, ".braid-cred");
|
|
2812
|
+
try {
|
|
2813
|
+
this.writeCredFile(credFile, cred.value.token);
|
|
2814
|
+
const probe = await this.probeRemote(g, mirror, remoteUrl, credFile, branch, meta.value.defaultBranch);
|
|
2815
|
+
if (!probe.ok) return probe;
|
|
2816
|
+
if (!isAutonomous(g) && probe.value.defaultBranch === branch) return this.defaultProtected(branch);
|
|
2817
|
+
if (probe.value.branchExists) {
|
|
2818
|
+
const ff = await this.fastForwardCheck(mirror, branch);
|
|
2819
|
+
if (ff) return ff;
|
|
2820
|
+
}
|
|
2821
|
+
const policy = await this.policyDiff(g, mirror, probe.value.branchExists);
|
|
2822
|
+
if (policy) return policy;
|
|
2823
|
+
if (!this.sameIdentity(wt, id)) return this.worktreeChanged("push", mirror);
|
|
2824
|
+
const pushed = await this.git([...credentialHelperArgs(credFile), "push", "--quiet", "--no-verify", remoteUrl, `refs/braid/incoming:refs/heads/${branch}`], { gitDir: mirror }, true);
|
|
2825
|
+
if (pushed.code !== 0) {
|
|
2826
|
+
if (/\b(401|403)\b|Authentication failed|could not read Username/i.test(pushed.stderr)) this.deps.resolver.dropCredential(listed);
|
|
2827
|
+
return fail(502, "push_failed", `GitHub rejected the push: ${oneLine(pushed.stderr)}. Run braid_git_fetch and braid_git_status, then try again.`);
|
|
2828
|
+
}
|
|
2829
|
+
return {
|
|
2830
|
+
ok: true,
|
|
2831
|
+
text: `Pushed ${sha.sha.slice(0, 12)} to ${repo} branch ${branch}.
|
|
2832
|
+
Compare: https://github.com/${repo}/compare/${encodeURIComponent(branch)}`,
|
|
2833
|
+
data: { branch, sha: sha.sha }
|
|
2834
|
+
};
|
|
2835
|
+
} finally {
|
|
2836
|
+
this.removeCredFile(credFile);
|
|
2837
|
+
}
|
|
2838
|
+
});
|
|
2839
|
+
}
|
|
2840
|
+
async openPrInner(channelId, args) {
|
|
2841
|
+
const gate = this.gateChecks(channelId);
|
|
2842
|
+
if (gate) return gate;
|
|
2843
|
+
const grant = await this.singleGrant();
|
|
2844
|
+
if (!grant.ok) return grant;
|
|
2845
|
+
const branch = String(args.branch ?? "");
|
|
2846
|
+
const badHead = await this.checkBranch(branch);
|
|
2847
|
+
if (badHead) return badHead;
|
|
2848
|
+
const title = String(args.title ?? "").trim();
|
|
2849
|
+
if (!title) return fail(400, "invalid_title", "A pull request needs a non-empty title.");
|
|
2850
|
+
if (args.base !== void 0) {
|
|
2851
|
+
const badBase = await this.checkBranch(String(args.base));
|
|
2852
|
+
if (badBase) return badBase;
|
|
2853
|
+
}
|
|
2854
|
+
const cred = await this.credentialAndPolicy(grant.grant);
|
|
2855
|
+
if (!cred.ok) return cred;
|
|
2856
|
+
const g = cred.grant;
|
|
2857
|
+
const meta = await this.repoMeta(g, cred.value);
|
|
2858
|
+
if (!meta.ok) return meta;
|
|
2859
|
+
const pub = this.publicCheck(g, meta.value);
|
|
2860
|
+
if (pub) return pub;
|
|
2861
|
+
const base = args.base ?? meta.value.defaultBranch;
|
|
2862
|
+
if (!base) return fail(502, "default_branch_unknown", "Could not learn the repository's default branch; pass base explicitly.");
|
|
2863
|
+
const draft = !isAutonomous(g);
|
|
2864
|
+
const body = { title, ...args.body !== void 0 ? { body: String(args.body) } : {}, head: branch, base, draft };
|
|
2865
|
+
const res = await this.api(grant.grant, cred.value.token, "POST", `/repos/${this.repoPath(g)}/pulls`, body);
|
|
2866
|
+
if (!res.ok) return res;
|
|
2867
|
+
const num = res.json.number;
|
|
2868
|
+
const url = res.json.html_url;
|
|
2869
|
+
this.log(`pr-open ${g.repoOwner}/${g.repoName} ${logSafe(branch)} #${String(num)} ok`);
|
|
2870
|
+
return {
|
|
2871
|
+
ok: true,
|
|
2872
|
+
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." : ""}`,
|
|
2873
|
+
data: { number: num, url }
|
|
2874
|
+
};
|
|
2875
|
+
}
|
|
2876
|
+
async mergePrInner(channelId, args) {
|
|
2877
|
+
const gate = this.gateChecks(channelId);
|
|
2878
|
+
if (gate) return gate;
|
|
2879
|
+
const grant = await this.singleGrant();
|
|
2880
|
+
if (!grant.ok) return grant;
|
|
2881
|
+
const num = Number(args.number);
|
|
2882
|
+
if (!Number.isInteger(num) || num <= 0) return fail(400, "invalid_number", "Pass the pull request number as a positive integer.");
|
|
2883
|
+
const method = args.method ?? "squash";
|
|
2884
|
+
if (!MERGE_METHODS.has(method)) return fail(400, "invalid_method", "The merge method must be merge, squash or rebase.");
|
|
2885
|
+
if (!isAutonomous(grant.grant)) return this.mergeRefused(num);
|
|
2886
|
+
const cred = await this.credentialAndPolicy(grant.grant);
|
|
2887
|
+
if (!cred.ok) return cred;
|
|
2888
|
+
const g = cred.grant;
|
|
2889
|
+
if (!isAutonomous(g)) return this.mergeRefused(num);
|
|
2890
|
+
const meta = await this.repoMeta(g, cred.value);
|
|
2891
|
+
if (!meta.ok) return meta;
|
|
2892
|
+
const pub = this.publicCheck(g, meta.value);
|
|
2893
|
+
if (pub) return pub;
|
|
2894
|
+
const res = await this.api(grant.grant, cred.value.token, "PUT", `/repos/${this.repoPath(g)}/pulls/${num}/merge`, { merge_method: method });
|
|
2895
|
+
if (!res.ok) return res;
|
|
2896
|
+
const sha = String(res.json.sha ?? "");
|
|
2897
|
+
this.log(`pr-merge ${g.repoOwner}/${g.repoName} #${num} ok`);
|
|
2898
|
+
return { ok: true, text: `Pull request #${num} in ${g.repoOwner}/${g.repoName} merged ${sha} (${method}).`, data: { number: num, sha } };
|
|
2899
|
+
}
|
|
2900
|
+
// ---- push pipeline steps ----
|
|
2901
|
+
/** D8.5: one ls-remote learns whether the branch exists (and proves the token works);
|
|
2902
|
+
* then the branch, or for a new branch the default branch, is fetched into the mirror. */
|
|
2903
|
+
async probeRemote(g, mirror, remoteUrl, credFile, branch, knownDefault) {
|
|
2904
|
+
const ls = await this.git([...credentialHelperArgs(credFile), "ls-remote", "--symref", remoteUrl, "HEAD", `refs/heads/${branch}`], { gitDir: mirror }, true);
|
|
2905
|
+
if (ls.code !== 0) return this.remoteRejected(g, ls.stderr);
|
|
2906
|
+
const branchExists = ls.stdout.split("\n").some((l) => l.endsWith(` refs/heads/${branch}`));
|
|
2907
|
+
const symref = /^ref: refs\/heads\/(\S+)\tHEAD$/m.exec(ls.stdout);
|
|
2908
|
+
const defaultBranch = knownDefault ?? (symref ? symref[1] : null);
|
|
2909
|
+
if (defaultBranch) this.knownDefault.set(`${g.repoOwner}/${g.repoName}`, defaultBranch);
|
|
2910
|
+
await this.git(["update-ref", "-d", "refs/braid/remote"], { gitDir: mirror });
|
|
2911
|
+
await this.git(["update-ref", "-d", "refs/braid/base"], { gitDir: mirror });
|
|
2912
|
+
const specs = [];
|
|
2913
|
+
if (branchExists) specs.push(`+refs/heads/${branch}:refs/braid/remote`);
|
|
2914
|
+
else if (defaultBranch && BRANCH_RE.test(defaultBranch)) specs.push(`+refs/heads/${defaultBranch}:refs/braid/base`);
|
|
2915
|
+
if (specs.length > 0) {
|
|
2916
|
+
const rf = await this.git([...credentialHelperArgs(credFile), "fetch", "--quiet", "--no-tags", remoteUrl, ...specs], { gitDir: mirror }, true);
|
|
2917
|
+
if (rf.code !== 0) return this.remoteRejected(g, rf.stderr);
|
|
2918
|
+
}
|
|
2919
|
+
return { ok: true, value: { branchExists, defaultBranch } };
|
|
2920
|
+
}
|
|
2921
|
+
/** D8.6: never --force; the remote tip must be an ancestor of what we push. */
|
|
2922
|
+
async fastForwardCheck(mirror, branch) {
|
|
2923
|
+
const ff = await this.git(["merge-base", "--is-ancestor", "refs/braid/remote", "refs/braid/incoming"], { gitDir: mirror });
|
|
2924
|
+
if (ff.code === 0) return null;
|
|
2925
|
+
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.`);
|
|
2926
|
+
}
|
|
2927
|
+
/** D8.8 (ruling R12): the changed paths, against the remote branch when it exists,
|
|
2928
|
+
* else against the merge-base with the default branch, else (orphan) the empty tree. */
|
|
2929
|
+
async policyDiff(g, mirror, branchExists) {
|
|
2930
|
+
const base = branchExists ? "refs/braid/remote" : await this.newBranchBase(mirror);
|
|
2931
|
+
if (!base) return fail(502, "diff_failed", "Could not compute what this push changes; try again.");
|
|
2932
|
+
const diff = await this.git(["diff", "--no-renames", "--name-only", "-z", base, "refs/braid/incoming", "--"], { gitDir: mirror });
|
|
2933
|
+
if (diff.code !== 0) return fail(502, "diff_failed", `Could not compute what this push changes: ${oneLine(diff.stderr)}.`);
|
|
2934
|
+
const files = diff.stdout.split("\0").filter(Boolean);
|
|
2935
|
+
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.");
|
|
2936
|
+
const risky = files.filter((f) => RISK_PATHS.some((re) => re.test(f)));
|
|
2937
|
+
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.`);
|
|
2938
|
+
return null;
|
|
2939
|
+
}
|
|
2940
|
+
async newBranchBase(mirror) {
|
|
2941
|
+
const has = await this.git(["rev-parse", "--verify", "-q", "refs/braid/base"], { gitDir: mirror });
|
|
2942
|
+
if (has.code === 0) {
|
|
2943
|
+
const mb = await this.git(["merge-base", "refs/braid/base", "refs/braid/incoming"], { gitDir: mirror });
|
|
2944
|
+
const sha = mb.stdout.trim();
|
|
2945
|
+
if (mb.code === 0 && sha) return sha;
|
|
2946
|
+
}
|
|
2947
|
+
const empty = await this.git(["hash-object", "-t", "tree", "--stdin"], { gitDir: mirror, input: "" });
|
|
2948
|
+
return empty.code === 0 ? empty.stdout.trim() : null;
|
|
2949
|
+
}
|
|
2950
|
+
// ---- helpers ----
|
|
2951
|
+
git(args, opts, network = false) {
|
|
2952
|
+
return runGit(args, { ...opts, timeoutMs: this.deps.gitTimeoutMs ?? (network ? NETWORK_TIMEOUT_MS : LOCAL_TIMEOUT_MS) });
|
|
2953
|
+
}
|
|
2954
|
+
/** G11: a thrown git or network failure still comes back as a sentence. */
|
|
2955
|
+
async guard(op, fn) {
|
|
2956
|
+
try {
|
|
2957
|
+
return await fn();
|
|
2958
|
+
} catch (e) {
|
|
2959
|
+
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.`);
|
|
2960
|
+
}
|
|
2961
|
+
}
|
|
2962
|
+
gateChecks(channelId) {
|
|
2963
|
+
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.");
|
|
2964
|
+
return null;
|
|
2965
|
+
}
|
|
2966
|
+
/** D7: one grant per agent for now; the repo is never chosen by the model. */
|
|
2967
|
+
async singleGrant() {
|
|
2968
|
+
let grants;
|
|
2969
|
+
try {
|
|
2970
|
+
grants = await this.deps.resolver.listGrants();
|
|
2971
|
+
} catch (e) {
|
|
2972
|
+
return fail(502, "grants_unavailable", `Could not load this agent's git grants (${errText(e)}). Try again in a minute.`);
|
|
2973
|
+
}
|
|
2974
|
+
if (grants.length === 0) return fail(404, "no_grant", `No git grant for this agent. Your owner can add one under Agents > ${this.deps.agentName} > Git access.`);
|
|
2975
|
+
if (grants.length > 1) return fail(409, "multiple_grants", `This agent has ${grants.length} git grants, and the gateway supports exactly one today. Ask the owner to keep one repository under Agents > ${this.deps.agentName} > Git access.`);
|
|
2976
|
+
return { ok: true, grant: grants[0] };
|
|
2977
|
+
}
|
|
2978
|
+
/** Credential plus the effective policy. When the listed grant is LOOSER than the
|
|
2979
|
+
* policy the cached mint carries (the owner loosened the grant after the mint), the
|
|
2980
|
+
* cached credential is dropped and re-minted once so the loosening takes effect now
|
|
2981
|
+
* instead of at the next scheduled re-mint; the result is still combined
|
|
2982
|
+
* most-restrictively, so a relay that really is tighter keeps winning. */
|
|
2983
|
+
async credentialAndPolicy(g) {
|
|
2984
|
+
let cred = await this.credential(g);
|
|
2985
|
+
if (!cred.ok) return cred;
|
|
2986
|
+
if (cred.value.policy && this.looser(g, cred.value.policy)) {
|
|
2987
|
+
this.deps.resolver.dropCredential(g);
|
|
2988
|
+
cred = await this.credential(g);
|
|
2989
|
+
if (!cred.ok) return cred;
|
|
2990
|
+
}
|
|
2991
|
+
const pol = this.effectivePolicy(g, cred.value);
|
|
2992
|
+
if (!pol.ok) return pol;
|
|
2993
|
+
return { ok: true, value: cred.value, grant: pol.grant };
|
|
2994
|
+
}
|
|
2995
|
+
looser(g, p) {
|
|
2996
|
+
return isAutonomous(g) && p.mode !== "autonomous" || g.allowWorkflows && !p.allowWorkflows || g.allowRiskPaths && !p.allowRiskPaths || g.allowPublic && !p.allowPublic;
|
|
2997
|
+
}
|
|
2998
|
+
/** The mint's policy and the listed grant are combined most-restrictively, so a
|
|
2999
|
+
* tightening seen by either source takes effect at once. The repos must agree. */
|
|
3000
|
+
effectivePolicy(g, cred) {
|
|
3001
|
+
const p = cred.policy;
|
|
3002
|
+
if (!p) return { ok: true, grant: g };
|
|
3003
|
+
if (p.repo.toLowerCase() !== `${g.repoOwner}/${g.repoName}`.toLowerCase()) {
|
|
3004
|
+
this.deps.resolver.dropCredential(g);
|
|
3005
|
+
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.`);
|
|
3006
|
+
}
|
|
3007
|
+
return {
|
|
3008
|
+
ok: true,
|
|
3009
|
+
grant: {
|
|
3010
|
+
...g,
|
|
3011
|
+
mode: isAutonomous(g) && p.mode === "autonomous" ? "autonomous" : "pr-only",
|
|
3012
|
+
allowWorkflows: g.allowWorkflows && p.allowWorkflows,
|
|
3013
|
+
allowRiskPaths: g.allowRiskPaths && p.allowRiskPaths,
|
|
3014
|
+
allowPublic: g.allowPublic && p.allowPublic
|
|
3015
|
+
}
|
|
3016
|
+
};
|
|
3017
|
+
}
|
|
3018
|
+
/** Validates the model-supplied path, then uses only the realpath-resolved location. */
|
|
3019
|
+
worktree(channelId, path2) {
|
|
3020
|
+
const p = path2 ?? this.deps.cwdFor(channelId);
|
|
3021
|
+
const v = validateWorktree(p, this.deps.writableRoots());
|
|
3022
|
+
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.`);
|
|
3023
|
+
return { ok: true, path: dirname4(v.gitDir), gitDir: v.gitDir };
|
|
3024
|
+
}
|
|
3025
|
+
/** R13: what must not change between validation and the last use of the worktree. */
|
|
3026
|
+
identity(wt) {
|
|
3027
|
+
try {
|
|
3028
|
+
const d = lstatSync4(wt.path);
|
|
3029
|
+
const gd = lstatSync4(wt.gitDir);
|
|
3030
|
+
if (!d.isDirectory() || !gd.isDirectory()) return null;
|
|
3031
|
+
if (existsSync6(join7(wt.gitDir, "objects", "info", "alternates"))) return null;
|
|
3032
|
+
if (CONFIG_INCLUDE_RE.test(readConfig(wt.gitDir))) return null;
|
|
3033
|
+
return { dirDev: d.dev, dirIno: d.ino, gitDev: gd.dev, gitIno: gd.ino };
|
|
3034
|
+
} catch {
|
|
3035
|
+
return null;
|
|
3036
|
+
}
|
|
3037
|
+
}
|
|
3038
|
+
sameIdentity(wt, before) {
|
|
3039
|
+
const now = this.identity(wt);
|
|
3040
|
+
return !!before && !!now && before.dirDev === now.dirDev && before.dirIno === now.dirIno && before.gitDev === now.gitDev && before.gitIno === now.gitIno;
|
|
3041
|
+
}
|
|
3042
|
+
async worktreeChanged(op, mirror) {
|
|
3043
|
+
if (mirror) await this.git(["update-ref", "-d", "refs/braid/incoming"], { gitDir: mirror });
|
|
3044
|
+
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.`);
|
|
3045
|
+
}
|
|
3046
|
+
/** Only a refusal-text hint: the worktree's origin/HEAD, if the path validates. */
|
|
3047
|
+
async worktreeDefaultHint(channelId, path2) {
|
|
3048
|
+
const wt = this.worktree(channelId, path2);
|
|
3049
|
+
if (!wt.ok) return null;
|
|
3050
|
+
const r = await this.git(["symbolic-ref", "-q", "--short", "refs/remotes/origin/HEAD"], { gitDir: wt.gitDir, cwd: wt.path });
|
|
3051
|
+
const s = r.stdout.trim();
|
|
3052
|
+
return r.code === 0 && s.startsWith("origin/") ? s.slice("origin/".length) : null;
|
|
3053
|
+
}
|
|
3054
|
+
async credential(g) {
|
|
3055
|
+
try {
|
|
3056
|
+
return { ok: true, value: await this.deps.resolver.credentialFor(g) };
|
|
3057
|
+
} catch (e) {
|
|
3058
|
+
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.`);
|
|
3059
|
+
}
|
|
3060
|
+
}
|
|
3061
|
+
/** Visibility and default branch: from the mint for app grants; looked up for a PAT. */
|
|
3062
|
+
async repoMeta(g, cred) {
|
|
3063
|
+
let meta;
|
|
3064
|
+
if (cred.private !== null && cred.defaultBranch) {
|
|
3065
|
+
meta = { private: cred.private, defaultBranch: cred.defaultBranch };
|
|
3066
|
+
} else {
|
|
3067
|
+
const res = await this.api(g, cred.token, "GET", `/repos/${this.repoPath(g)}`);
|
|
3068
|
+
if (!res.ok) return res;
|
|
3069
|
+
const j = res.json;
|
|
3070
|
+
meta = {
|
|
3071
|
+
private: cred.private ?? (typeof j.private === "boolean" ? j.private : null),
|
|
3072
|
+
defaultBranch: cred.defaultBranch ?? (typeof j.default_branch === "string" ? j.default_branch : null)
|
|
3073
|
+
};
|
|
3074
|
+
}
|
|
3075
|
+
if (meta.defaultBranch) this.knownDefault.set(`${g.repoOwner}/${g.repoName}`, meta.defaultBranch);
|
|
3076
|
+
return { ok: true, value: meta };
|
|
3077
|
+
}
|
|
3078
|
+
/** D9: fail closed when visibility is unknown. */
|
|
3079
|
+
publicCheck(g, meta) {
|
|
3080
|
+
if (meta.private === true || g.allowPublic) return null;
|
|
3081
|
+
const why = meta.private === false ? "is public" : "has unknown visibility";
|
|
3082
|
+
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.`);
|
|
3083
|
+
}
|
|
3084
|
+
/** D8.2: the regex, then git's own rules (trailing "/", "//", trailing ".", "/.x", "@{"). */
|
|
3085
|
+
async checkBranch(branch) {
|
|
3086
|
+
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.`);
|
|
3087
|
+
if (!BRANCH_RE.test(branch)) return bad;
|
|
3088
|
+
const r = await this.git(["check-ref-format", "--branch", branch], {});
|
|
3089
|
+
return r.code === 0 ? null : bad;
|
|
3090
|
+
}
|
|
3091
|
+
defaultProtected(branch) {
|
|
3092
|
+
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.`);
|
|
3093
|
+
}
|
|
3094
|
+
outsideNamespace() {
|
|
3095
|
+
const ns = this.namespace();
|
|
3096
|
+
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.`);
|
|
3097
|
+
}
|
|
3098
|
+
mergeRefused(num) {
|
|
3099
|
+
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.`);
|
|
3100
|
+
}
|
|
3101
|
+
namespace() {
|
|
3102
|
+
return `braid/${this.deps.agentName}/`;
|
|
3103
|
+
}
|
|
3104
|
+
rulesText(g) {
|
|
3105
|
+
const parts = [];
|
|
3106
|
+
const auto = isAutonomous(g);
|
|
3107
|
+
if (!auto) parts.push(`push only to branches under ${this.namespace()}, never the default branch; pull requests open as drafts and a human merges`);
|
|
3108
|
+
else parts.push("push to any branch, including the default branch; braid_merge_pr is allowed");
|
|
3109
|
+
parts.push("fast-forward pushes only, no deletes, no tags");
|
|
3110
|
+
parts.push(g.allowWorkflows ? "changes under .github/workflows are allowed" : "changes under .github/workflows are refused");
|
|
3111
|
+
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");
|
|
3112
|
+
parts.push(g.allowPublic ? "public repositories are allowed" : "writes to a public repository are refused");
|
|
3113
|
+
return parts.join("; ") + ".";
|
|
3114
|
+
}
|
|
3115
|
+
async resolveSha(wt, rev) {
|
|
3116
|
+
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.`);
|
|
3117
|
+
if (typeof rev !== "string" || !rev || rev.startsWith("-") || /[\0\n]/.test(rev)) return bad;
|
|
3118
|
+
const r = await this.git(["rev-parse", "--verify", "-q", "--end-of-options", `${rev}^{commit}`], { gitDir: wt.gitDir, cwd: wt.path });
|
|
3119
|
+
const sha = r.stdout.trim();
|
|
3120
|
+
if (r.code !== 0 || !/^[0-9a-f]{40,64}$/.test(sha)) return bad;
|
|
3121
|
+
return { ok: true, sha };
|
|
3122
|
+
}
|
|
3123
|
+
mirrorPath(g) {
|
|
3124
|
+
return join7(this.deps.configDir, "git", relayHostSegment(this.deps.relayUrl), safeSegment(this.deps.agentName), `${safeSegment(g.repoOwner)}__${safeSegment(g.repoName)}.git`);
|
|
3125
|
+
}
|
|
3126
|
+
/** Creates the bare mirror (0700) when absent and removes any stale credential file. */
|
|
3127
|
+
async ensureMirror(mirror) {
|
|
3128
|
+
if (!existsSync6(join7(mirror, "HEAD"))) {
|
|
3129
|
+
mkdirSync5(mirror, { recursive: true, mode: 448 });
|
|
3130
|
+
const r = await this.git(["init", "--bare", "--quiet", mirror], {});
|
|
3131
|
+
if (r.code !== 0) return fail(502, "mirror_init_failed", `Could not prepare the bridge's git mirror: ${oneLine(r.stderr)}.`);
|
|
3132
|
+
}
|
|
3133
|
+
try {
|
|
3134
|
+
chmodSync4(mirror, 448);
|
|
3135
|
+
} catch {
|
|
3136
|
+
}
|
|
3137
|
+
this.removeCredFile(join7(mirror, ".braid-cred"));
|
|
3138
|
+
return null;
|
|
3139
|
+
}
|
|
3140
|
+
remoteUrl(g) {
|
|
3141
|
+
return this.deps.remoteUrlFor ? this.deps.remoteUrlFor(g) : `https://github.com/${this.repoPath(g)}.git`;
|
|
3142
|
+
}
|
|
3143
|
+
repoPath(g) {
|
|
3144
|
+
return `${encodeURIComponent(g.repoOwner)}/${encodeURIComponent(g.repoName)}`;
|
|
3145
|
+
}
|
|
3146
|
+
writeCredFile(file, token) {
|
|
3147
|
+
writeFileSync5(file, `https://x-access-token:${token}@github.com
|
|
3148
|
+
`, { mode: 384 });
|
|
3149
|
+
try {
|
|
3150
|
+
chmodSync4(file, 384);
|
|
3151
|
+
} catch {
|
|
3152
|
+
}
|
|
3153
|
+
}
|
|
3154
|
+
removeCredFile(file) {
|
|
3155
|
+
if (!existsSync6(file)) return;
|
|
3156
|
+
try {
|
|
3157
|
+
writeFileSync5(file, "");
|
|
3158
|
+
} catch {
|
|
3159
|
+
}
|
|
3160
|
+
try {
|
|
3161
|
+
unlinkSync3(file);
|
|
3162
|
+
} catch {
|
|
3163
|
+
}
|
|
3164
|
+
}
|
|
3165
|
+
remoteRejected(g, stderr) {
|
|
3166
|
+
this.deps.resolver.dropCredential(g);
|
|
3167
|
+
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.`);
|
|
3168
|
+
}
|
|
3169
|
+
async api(g, token, method, path2, body) {
|
|
3170
|
+
let res;
|
|
3171
|
+
try {
|
|
3172
|
+
res = await this.fetchFn(`${GITHUB_API}${path2}`, {
|
|
3173
|
+
method,
|
|
3174
|
+
headers: {
|
|
3175
|
+
Authorization: `Bearer ${token}`,
|
|
3176
|
+
Accept: "application/vnd.github+json",
|
|
3177
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
3178
|
+
"User-Agent": "braid-bridge",
|
|
3179
|
+
...body !== void 0 ? { "Content-Type": "application/json" } : {}
|
|
3180
|
+
},
|
|
3181
|
+
...body !== void 0 ? { body: JSON.stringify(body) } : {}
|
|
3182
|
+
});
|
|
3183
|
+
} catch (e) {
|
|
3184
|
+
return fail(502, "github_unreachable", `Could not reach the GitHub API (${errText(e)}). Try again in a minute.`);
|
|
3185
|
+
}
|
|
3186
|
+
let json = null;
|
|
3187
|
+
try {
|
|
3188
|
+
json = await res.json();
|
|
3189
|
+
} catch {
|
|
3190
|
+
json = null;
|
|
3191
|
+
}
|
|
3192
|
+
if (!res.ok) {
|
|
3193
|
+
if (res.status === 401) this.deps.resolver.dropCredential(g);
|
|
3194
|
+
const msg = json && typeof json === "object" && typeof json.message === "string" ? json.message : res.statusText;
|
|
3195
|
+
const errs = json && typeof json === "object" && Array.isArray(json.errors) ? json.errors.map((e) => typeof e?.message === "string" ? e.message : "").filter(Boolean).join("; ") : "";
|
|
3196
|
+
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.`);
|
|
3197
|
+
}
|
|
3198
|
+
return { ok: true, json: json ?? {} };
|
|
3199
|
+
}
|
|
3200
|
+
/** One mirror operation at a time: the incoming/remote refs and the credential file are shared. */
|
|
3201
|
+
serialize(key, fn) {
|
|
3202
|
+
const prev = this.locks.get(key) ?? Promise.resolve();
|
|
3203
|
+
const run = prev.then(fn, fn);
|
|
3204
|
+
const tail = run.then(() => void 0, () => void 0);
|
|
3205
|
+
this.locks.set(key, tail);
|
|
3206
|
+
void tail.then(() => {
|
|
3207
|
+
if (this.locks.get(key) === tail) this.locks.delete(key);
|
|
3208
|
+
});
|
|
3209
|
+
return run;
|
|
3210
|
+
}
|
|
3211
|
+
log(line) {
|
|
3212
|
+
const l = scrub(line);
|
|
3213
|
+
if (this.deps.log) this.deps.log(l);
|
|
3214
|
+
else console.log(`[git-gateway] ${l}`);
|
|
3215
|
+
}
|
|
3216
|
+
};
|
|
3217
|
+
|
|
3218
|
+
// src/gitGateway/grants.ts
|
|
3219
|
+
import { chmodSync as chmodSync5, existsSync as existsSync7, mkdirSync as mkdirSync6, readFileSync as readFileSync8, renameSync as renameSync3, unlinkSync as unlinkSync4, writeFileSync as writeFileSync6 } from "fs";
|
|
3220
|
+
import { dirname as dirname5, join as join8 } from "path";
|
|
3221
|
+
var GRANTS_TTL_MS = 6e4;
|
|
3222
|
+
var REMINT_WINDOW_MS = 5 * 6e4;
|
|
3223
|
+
function localGitGrantPath(dir, relayUrl, agentName) {
|
|
3224
|
+
const safeName = agentName.replace(/[^a-zA-Z0-9._-]/g, "_");
|
|
3225
|
+
return join8(dir, "git-grants", relayHostSegment(relayUrl), `${safeName}.json`);
|
|
3226
|
+
}
|
|
3227
|
+
function loadLocalGitGrant(dir, relayUrl, agentName) {
|
|
3228
|
+
let raw;
|
|
3229
|
+
try {
|
|
3230
|
+
raw = JSON.parse(readFileSync8(localGitGrantPath(dir, relayUrl, agentName), "utf8"));
|
|
3231
|
+
} catch {
|
|
3232
|
+
return null;
|
|
3233
|
+
}
|
|
3234
|
+
const o = raw;
|
|
3235
|
+
if (!o || typeof o !== "object") return null;
|
|
3236
|
+
if (typeof o.token !== "string" || !o.token) return null;
|
|
3237
|
+
if (typeof o.repoOwner !== "string" || !o.repoOwner || typeof o.repoName !== "string" || !o.repoName) return null;
|
|
3238
|
+
if (o.mode !== "autonomous" && o.mode !== "pr-only") return null;
|
|
3239
|
+
return {
|
|
3240
|
+
source: "pat",
|
|
3241
|
+
repoOwner: o.repoOwner,
|
|
3242
|
+
repoName: o.repoName,
|
|
3243
|
+
mode: o.mode,
|
|
3244
|
+
allowWorkflows: o.allowWorkflows === true,
|
|
3245
|
+
allowRiskPaths: o.allowRiskPaths === true,
|
|
3246
|
+
allowPublic: o.allowPublic === true,
|
|
3247
|
+
token: o.token
|
|
3248
|
+
};
|
|
3249
|
+
}
|
|
3250
|
+
function saveLocalGitGrant(dir, relayUrl, agentName, grant, deps) {
|
|
3251
|
+
const file = localGitGrantPath(dir, relayUrl, agentName);
|
|
3252
|
+
mkdirSync6(dirname5(file), { recursive: true, mode: 448 });
|
|
3253
|
+
const body = {
|
|
3254
|
+
repoOwner: grant.repoOwner,
|
|
3255
|
+
repoName: grant.repoName,
|
|
3256
|
+
mode: grant.mode,
|
|
3257
|
+
allowWorkflows: grant.allowWorkflows,
|
|
3258
|
+
allowRiskPaths: grant.allowRiskPaths,
|
|
3259
|
+
allowPublic: grant.allowPublic,
|
|
3260
|
+
token: grant.token
|
|
3261
|
+
};
|
|
3262
|
+
const tmp = `${file}.tmp-${process.pid}`;
|
|
3263
|
+
try {
|
|
3264
|
+
writeFileSync6(tmp, JSON.stringify(body, null, 2) + "\n", { mode: 384 });
|
|
3265
|
+
renameSync3(tmp, file);
|
|
3266
|
+
} catch (err) {
|
|
3267
|
+
try {
|
|
3268
|
+
unlinkSync4(tmp);
|
|
3269
|
+
} catch {
|
|
3270
|
+
}
|
|
3271
|
+
throw err;
|
|
3272
|
+
}
|
|
3273
|
+
try {
|
|
3274
|
+
chmodSync5(file, 384);
|
|
3275
|
+
} catch {
|
|
3276
|
+
}
|
|
3277
|
+
const platform = deps?.platform ?? process.platform;
|
|
3278
|
+
if (platform === "win32") {
|
|
3279
|
+
const d = deps ?? productionWin32Deps();
|
|
3280
|
+
try {
|
|
3281
|
+
applyWin32Acl(file, resolveCurrentUser(d).sid, "file", d);
|
|
3282
|
+
} catch (err) {
|
|
3283
|
+
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)}`);
|
|
3284
|
+
}
|
|
3285
|
+
}
|
|
3286
|
+
return file;
|
|
3287
|
+
}
|
|
3288
|
+
function clearLocalGitGrant(dir, relayUrl, agentName) {
|
|
3289
|
+
const file = localGitGrantPath(dir, relayUrl, agentName);
|
|
3290
|
+
if (!existsSync7(file)) return false;
|
|
3291
|
+
try {
|
|
3292
|
+
unlinkSync4(file);
|
|
3293
|
+
return true;
|
|
3294
|
+
} catch {
|
|
3295
|
+
return false;
|
|
3296
|
+
}
|
|
3297
|
+
}
|
|
3298
|
+
var GrantResolver = class {
|
|
3299
|
+
constructor(deps) {
|
|
3300
|
+
this.deps = deps;
|
|
3301
|
+
this.now = deps.now ?? Date.now;
|
|
3302
|
+
}
|
|
3303
|
+
deps;
|
|
3304
|
+
grantsCache = null;
|
|
3305
|
+
// #private so an inspect/dump of the resolver can never print a token.
|
|
3306
|
+
#creds = /* @__PURE__ */ new Map();
|
|
3307
|
+
#pending = /* @__PURE__ */ new Map();
|
|
3308
|
+
now;
|
|
3309
|
+
async listGrants() {
|
|
3310
|
+
const out = [];
|
|
3311
|
+
if (this.deps.relay) {
|
|
3312
|
+
const t = this.now();
|
|
3313
|
+
if (!this.grantsCache || t - this.grantsCache.at >= GRANTS_TTL_MS) {
|
|
3314
|
+
const fresh = await this.deps.relay.fetchGitGrants();
|
|
3315
|
+
if (fresh !== null) this.grantsCache = { at: t, grants: fresh };
|
|
3316
|
+
}
|
|
3317
|
+
if (this.grantsCache) out.push(...this.grantsCache.grants);
|
|
3318
|
+
}
|
|
3319
|
+
const pat = loadLocalGitGrant(this.deps.configDir, this.deps.relayUrl, this.deps.agentName);
|
|
3320
|
+
if (pat) {
|
|
3321
|
+
const { token: _token, ...info } = pat;
|
|
3322
|
+
out.push(info);
|
|
3323
|
+
}
|
|
3324
|
+
return out;
|
|
3325
|
+
}
|
|
3326
|
+
async grantForRepo(owner, name) {
|
|
3327
|
+
const o = owner.toLowerCase();
|
|
3328
|
+
const n = name.toLowerCase();
|
|
3329
|
+
const all = await this.listGrants();
|
|
3330
|
+
return all.find((g) => g.repoOwner.toLowerCase() === o && g.repoName.toLowerCase() === n) ?? null;
|
|
3331
|
+
}
|
|
3332
|
+
async credentialFor(grant) {
|
|
3333
|
+
if (grant.source === "pat") {
|
|
3334
|
+
const pat = loadLocalGitGrant(this.deps.configDir, this.deps.relayUrl, this.deps.agentName);
|
|
3335
|
+
if (!pat) throw new Error("git grant: the local PAT grant is no longer present");
|
|
3336
|
+
if (pat.repoOwner.toLowerCase() !== grant.repoOwner.toLowerCase() || pat.repoName.toLowerCase() !== grant.repoName.toLowerCase()) {
|
|
3337
|
+
throw new Error(`git grant: the local PAT grant does not cover ${grant.repoOwner}/${grant.repoName}`);
|
|
3338
|
+
}
|
|
3339
|
+
return { token: pat.token, expiresAt: Infinity, private: null, defaultBranch: null };
|
|
3340
|
+
}
|
|
3341
|
+
const id = grant.id;
|
|
3342
|
+
if (!id || !this.deps.relay) throw new Error("git grant: no relay available to mint a token for this grant");
|
|
3343
|
+
const cached = this.#creds.get(id);
|
|
3344
|
+
if (cached && cached.expiresAt - this.now() > REMINT_WINDOW_MS) return cached;
|
|
3345
|
+
const inflight = this.#pending.get(id);
|
|
3346
|
+
if (inflight) return inflight;
|
|
3347
|
+
const relay = this.deps.relay;
|
|
3348
|
+
const p = (async () => {
|
|
3349
|
+
const m = await relay.mintGitToken(id);
|
|
3350
|
+
if (!m) throw new Error(`git grant: the relay would not mint a token for ${grant.repoOwner}/${grant.repoName}`);
|
|
3351
|
+
const cred = {
|
|
3352
|
+
token: m.token,
|
|
3353
|
+
expiresAt: m.expiresAt,
|
|
3354
|
+
private: m.private,
|
|
3355
|
+
defaultBranch: m.defaultBranch,
|
|
3356
|
+
policy: { repo: m.repo, mode: m.mode, allowWorkflows: m.allowWorkflows, allowRiskPaths: m.allowRiskPaths, allowPublic: m.allowPublic }
|
|
3357
|
+
};
|
|
3358
|
+
this.#creds.set(id, cred);
|
|
3359
|
+
return cred;
|
|
3360
|
+
})();
|
|
3361
|
+
this.#pending.set(id, p);
|
|
3362
|
+
try {
|
|
3363
|
+
return await p;
|
|
3364
|
+
} finally {
|
|
3365
|
+
this.#pending.delete(id);
|
|
3366
|
+
}
|
|
3367
|
+
}
|
|
3368
|
+
dropCredential(grant) {
|
|
3369
|
+
if (grant.id) this.#creds.delete(grant.id);
|
|
3370
|
+
}
|
|
3371
|
+
};
|
|
3372
|
+
|
|
2449
3373
|
// src/bridge.ts
|
|
2450
3374
|
var REFRESH_MARGIN_MS = 24 * 60 * 60 * 1e3;
|
|
2451
3375
|
var MIN_DELAY_MS = 60 * 1e3;
|
|
@@ -2457,6 +3381,15 @@ function computeRefreshDelay(exp, now) {
|
|
|
2457
3381
|
}
|
|
2458
3382
|
async function startBridge(cfg, relay, deps) {
|
|
2459
3383
|
const sessions = /* @__PURE__ */ new Map();
|
|
3384
|
+
const gitConfigDir = configDir(process.env);
|
|
3385
|
+
const gitGateway = deps.gitGateway ?? new GitGateway({
|
|
3386
|
+
resolver: new GrantResolver({ relay, configDir: gitConfigDir, relayUrl: cfg.relayWsUrl, agentName: cfg.agentName }),
|
|
3387
|
+
configDir: gitConfigDir,
|
|
3388
|
+
relayUrl: cfg.relayWsUrl,
|
|
3389
|
+
agentName: cfg.agentName,
|
|
3390
|
+
cwdFor: (ch) => handoffCwdFor(cfg, ch),
|
|
3391
|
+
writableRoots: () => resolveScope(cfg, cfg.agent, process.env).runtimeConfig.filesystem.allowWrite
|
|
3392
|
+
});
|
|
2460
3393
|
const channelNames = /* @__PURE__ */ new Map();
|
|
2461
3394
|
const hub = new ObservationHub();
|
|
2462
3395
|
const service = process.env.BRAID_SERVICE === "1";
|
|
@@ -2674,7 +3607,11 @@ async function startBridge(cfg, relay, deps) {
|
|
|
2674
3607
|
// merged ChannelSession constructor param order (session.ts).
|
|
2675
3608
|
deps.makeEphemeralAdapter ? () => deps.makeEphemeralAdapter(cfg) : void 0
|
|
2676
3609
|
);
|
|
2677
|
-
const broker = new SourceBroker(channelId, relay);
|
|
3610
|
+
const broker = new SourceBroker(channelId, relay, process.platform, gitGateway);
|
|
3611
|
+
session.onLisaFreshTurn = (active) => {
|
|
3612
|
+
broker.setGitEnabled(!active);
|
|
3613
|
+
gitGateway.setChannelGitEnabled(channelId, !active);
|
|
3614
|
+
};
|
|
2678
3615
|
const brokerInfo = await broker.start();
|
|
2679
3616
|
const allowedReactions = await relay.fetchAllowedReactions?.().catch(() => []) ?? [];
|
|
2680
3617
|
const mcpServers = [{
|
|
@@ -3013,10 +3950,11 @@ function startUpdateNotifier(opts) {
|
|
|
3013
3950
|
}
|
|
3014
3951
|
|
|
3015
3952
|
// src/cli.ts
|
|
3016
|
-
import { readFileSync as
|
|
3953
|
+
import { readFileSync as readFileSync12 } from "fs";
|
|
3017
3954
|
import { fileURLToPath as fileURLToPath3 } from "url";
|
|
3018
3955
|
|
|
3019
3956
|
// src/cliArgs.ts
|
|
3957
|
+
var GIT_REPO_RE = /^[A-Za-z0-9_.-]{1,100}\/[A-Za-z0-9_.-]{1,100}$/;
|
|
3020
3958
|
var USAGE = `Usage: braid <command>
|
|
3021
3959
|
|
|
3022
3960
|
Commands:
|
|
@@ -3043,6 +3981,17 @@ Commands:
|
|
|
3043
3981
|
(no network; local inspection only)
|
|
3044
3982
|
scope allow <agent> <host>
|
|
3045
3983
|
Approve a custom model endpoint host for a saved agent (BL-148)
|
|
3984
|
+
scope allow-code-hosts <agent>
|
|
3985
|
+
Let THIS agent's sandbox reach github.com/gitlab.com/bitbucket.org
|
|
3986
|
+
directly (accepts the exfiltration risk; prefer the git tools)
|
|
3987
|
+
git-auth set <agent> <owner/repo> --mode <autonomous|pr-only>
|
|
3988
|
+
[--allow-workflows] [--allow-risk-paths] [--allow-public] --token-stdin
|
|
3989
|
+
Give a saved agent git access to ONE repo with a fine-grained
|
|
3990
|
+
token read from stdin (local fallback; prefer the Braid GitHub
|
|
3991
|
+
App in the web app). autonomous = push anywhere and merge;
|
|
3992
|
+
pr-only = braid/<agent>/* branches, a human merges.
|
|
3993
|
+
git-auth show <agent> Show the agent's local git grant (never the token)
|
|
3994
|
+
git-auth clear <agent> Remove the agent's local git grant
|
|
3046
3995
|
telemetry [status|enable|disable]
|
|
3047
3996
|
Show or change usage telemetry (tool-mode +
|
|
3048
3997
|
blocked-tool-call signals only; never content, paths, or
|
|
@@ -3125,6 +4074,11 @@ function parseCliArgs(argv) {
|
|
|
3125
4074
|
}
|
|
3126
4075
|
return { kind: "scope-allow", agent: agent2, host };
|
|
3127
4076
|
}
|
|
4077
|
+
if (argv[1] === "allow-code-hosts") {
|
|
4078
|
+
const agent2 = argv[2] && !argv[2].startsWith("-") ? argv[2].trim() : void 0;
|
|
4079
|
+
if (!agent2) return { kind: "usage", error: `Usage: braid scope allow-code-hosts <agent>` };
|
|
4080
|
+
return { kind: "scope-allow-code-hosts", agent: agent2 };
|
|
4081
|
+
}
|
|
3128
4082
|
const agent = argv[1] && !argv[1].startsWith("-") ? argv[1].trim() : void 0;
|
|
3129
4083
|
return { kind: "scope", agent };
|
|
3130
4084
|
}
|
|
@@ -3231,6 +4185,71 @@ function parseCliArgs(argv) {
|
|
|
3231
4185
|
if (first === "version" || first === "--version" || first === "-v") {
|
|
3232
4186
|
return { kind: "version" };
|
|
3233
4187
|
}
|
|
4188
|
+
if (first === "git-auth") {
|
|
4189
|
+
const verb = argv[1];
|
|
4190
|
+
const rest = argv.slice(2);
|
|
4191
|
+
if (verb === "show" || verb === "clear") {
|
|
4192
|
+
const agent2 = rest[0] && !rest[0].startsWith("-") ? rest[0].trim() : void 0;
|
|
4193
|
+
if (!agent2 || rest.length > 1) return { kind: "usage", error: `Usage: braid git-auth ${verb} <agent>` };
|
|
4194
|
+
return verb === "show" ? { kind: "git-auth-show", agent: agent2 } : { kind: "git-auth-clear", agent: agent2 };
|
|
4195
|
+
}
|
|
4196
|
+
if (verb !== "set") return { kind: "usage", error: "Usage: braid git-auth <set|show|clear> <agent> ..." };
|
|
4197
|
+
let agent;
|
|
4198
|
+
let repo;
|
|
4199
|
+
let mode;
|
|
4200
|
+
let tokenStdin = false;
|
|
4201
|
+
let allowWorkflows = false;
|
|
4202
|
+
let allowRiskPaths = false;
|
|
4203
|
+
let allowPublic = false;
|
|
4204
|
+
for (let i = 0; i < rest.length; i++) {
|
|
4205
|
+
const a = rest[i];
|
|
4206
|
+
if (a === "--token-stdin") {
|
|
4207
|
+
tokenStdin = true;
|
|
4208
|
+
continue;
|
|
4209
|
+
}
|
|
4210
|
+
if (a === "--allow-workflows") {
|
|
4211
|
+
allowWorkflows = true;
|
|
4212
|
+
continue;
|
|
4213
|
+
}
|
|
4214
|
+
if (a === "--allow-risk-paths") {
|
|
4215
|
+
allowRiskPaths = true;
|
|
4216
|
+
continue;
|
|
4217
|
+
}
|
|
4218
|
+
if (a === "--allow-public") {
|
|
4219
|
+
allowPublic = true;
|
|
4220
|
+
continue;
|
|
4221
|
+
}
|
|
4222
|
+
if (a === "--mode") {
|
|
4223
|
+
const v = rest[i + 1]?.trim();
|
|
4224
|
+
if (v === void 0 || v === "" || v.startsWith("--")) return { kind: "usage", error: "Missing value for --mode (autonomous or pr-only)" };
|
|
4225
|
+
if (v !== "autonomous" && v !== "pr-only") return { kind: "usage", error: "Invalid --mode. Expected autonomous or pr-only" };
|
|
4226
|
+
mode = v;
|
|
4227
|
+
i++;
|
|
4228
|
+
continue;
|
|
4229
|
+
}
|
|
4230
|
+
if (a.startsWith("-")) return { kind: "usage", error: `Unknown flag for git-auth set: ${a}` };
|
|
4231
|
+
if (agent === void 0) {
|
|
4232
|
+
agent = a.trim();
|
|
4233
|
+
continue;
|
|
4234
|
+
}
|
|
4235
|
+
if (repo === void 0) {
|
|
4236
|
+
repo = a.trim();
|
|
4237
|
+
continue;
|
|
4238
|
+
}
|
|
4239
|
+
return {
|
|
4240
|
+
kind: "usage",
|
|
4241
|
+
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"
|
|
4242
|
+
};
|
|
4243
|
+
}
|
|
4244
|
+
if (!agent || !repo) return { kind: "usage", error: "Usage: braid git-auth set <agent> <owner/repo> --mode <autonomous|pr-only> --token-stdin" };
|
|
4245
|
+
if (!GIT_REPO_RE.test(repo)) return { kind: "usage", error: "git-auth set needs <owner/repo> (letters, digits, . _ -)" };
|
|
4246
|
+
if (!mode) return { kind: "usage", error: "git-auth set requires --mode <autonomous|pr-only>" };
|
|
4247
|
+
if (!tokenStdin) {
|
|
4248
|
+
return { kind: "usage", error: "git-auth set requires --token-stdin (the token is read from stdin, never from arguments)." };
|
|
4249
|
+
}
|
|
4250
|
+
const [repoOwner, repoName] = repo.split("/");
|
|
4251
|
+
return { kind: "git-auth-set", agent, repoOwner, repoName, mode, allowWorkflows, allowRiskPaths, allowPublic };
|
|
4252
|
+
}
|
|
3234
4253
|
if (first === "enroll") {
|
|
3235
4254
|
let inviteStdin = false;
|
|
3236
4255
|
let json = false;
|
|
@@ -3292,14 +4311,14 @@ function parseCliArgs(argv) {
|
|
|
3292
4311
|
// src/launchTui.ts
|
|
3293
4312
|
import { spawn } from "child_process";
|
|
3294
4313
|
import { createRequire } from "module";
|
|
3295
|
-
import { dirname as
|
|
4314
|
+
import { dirname as dirname6, join as join9 } from "path";
|
|
3296
4315
|
function resolveTuiEntry() {
|
|
3297
4316
|
try {
|
|
3298
4317
|
const require2 = createRequire(import.meta.url);
|
|
3299
4318
|
const pkgPath = require2.resolve("@snowyroad/braid-tui/package.json");
|
|
3300
4319
|
const pkg = require2("@snowyroad/braid-tui/package.json");
|
|
3301
4320
|
const rel = typeof pkg.bin === "string" ? pkg.bin : pkg.bin?.["braid-tui"];
|
|
3302
|
-
return rel ?
|
|
4321
|
+
return rel ? join9(dirname6(pkgPath), rel) : null;
|
|
3303
4322
|
} catch {
|
|
3304
4323
|
return null;
|
|
3305
4324
|
}
|
|
@@ -3311,17 +4330,17 @@ function runTui(args, env, selfCliPath) {
|
|
|
3311
4330
|
return Promise.resolve(1);
|
|
3312
4331
|
}
|
|
3313
4332
|
const childEnv = { ...env, ARP_TUI_BRIDGE_CMD: `${process.execPath} ${selfCliPath}` };
|
|
3314
|
-
return new Promise((
|
|
4333
|
+
return new Promise((resolve2) => {
|
|
3315
4334
|
const child = spawn(process.execPath, [entry, ...args], { stdio: "inherit", env: childEnv });
|
|
3316
|
-
child.on("error", () =>
|
|
3317
|
-
child.on("close", (code) =>
|
|
4335
|
+
child.on("error", () => resolve2(1));
|
|
4336
|
+
child.on("close", (code) => resolve2(code ?? 1));
|
|
3318
4337
|
});
|
|
3319
4338
|
}
|
|
3320
4339
|
|
|
3321
4340
|
// src/enroll.ts
|
|
3322
4341
|
var ENROLL_STDIN_MAX_BYTES = 16 * 1024;
|
|
3323
|
-
function readBoundedStdin(stream, maxBytes) {
|
|
3324
|
-
return new Promise((
|
|
4342
|
+
function readBoundedStdin(stream, maxBytes, label = "invite") {
|
|
4343
|
+
return new Promise((resolve2, reject) => {
|
|
3325
4344
|
const chunks = [];
|
|
3326
4345
|
let total = 0;
|
|
3327
4346
|
let settled = false;
|
|
@@ -3337,12 +4356,12 @@ function readBoundedStdin(stream, maxBytes) {
|
|
|
3337
4356
|
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
3338
4357
|
total += buf.length;
|
|
3339
4358
|
if (total > maxBytes) {
|
|
3340
|
-
finish(() => reject(new Error(`stdin exceeded the ${maxBytes / 1024}KiB
|
|
4359
|
+
finish(() => reject(new Error(`stdin exceeded the ${maxBytes / 1024}KiB ${label} limit`)));
|
|
3341
4360
|
return;
|
|
3342
4361
|
}
|
|
3343
4362
|
chunks.push(buf);
|
|
3344
4363
|
};
|
|
3345
|
-
const onEnd = () => finish(() =>
|
|
4364
|
+
const onEnd = () => finish(() => resolve2(Buffer.concat(chunks).toString("utf8").trim()));
|
|
3346
4365
|
const onError = (err) => finish(() => reject(err));
|
|
3347
4366
|
stream.on("data", onData);
|
|
3348
4367
|
stream.on("end", onEnd);
|
|
@@ -3404,7 +4423,7 @@ async function runEnroll(opts, io, env) {
|
|
|
3404
4423
|
...opts.model ? { model: opts.model } : {}
|
|
3405
4424
|
};
|
|
3406
4425
|
const file = persistOrNeedsHuman(dir, stored);
|
|
3407
|
-
assertKeystorePerms(dir, file);
|
|
4426
|
+
assertKeystorePerms(dir, file, void 0, [localGitGrantPath(dir, inv.relayUrl, bundle.agentName)]);
|
|
3408
4427
|
const minted = await mintAccessToken(relayHttpUrl, stored.agentKey);
|
|
3409
4428
|
persistOrNeedsHuman(dir, { ...stored, agentKey: minted.agentKey });
|
|
3410
4429
|
const name = sanitizeForTty(bundle.agentName);
|
|
@@ -3479,13 +4498,13 @@ function mergeFirstRunPatch(existing, patch) {
|
|
|
3479
4498
|
}
|
|
3480
4499
|
function promptYesNoDefaultYes(question, input, output) {
|
|
3481
4500
|
const rl = createInterface({ input, output });
|
|
3482
|
-
return new Promise((
|
|
4501
|
+
return new Promise((resolve2) => {
|
|
3483
4502
|
let resolved = false;
|
|
3484
4503
|
const done = (result) => {
|
|
3485
4504
|
if (resolved) return;
|
|
3486
4505
|
resolved = true;
|
|
3487
4506
|
rl.close();
|
|
3488
|
-
|
|
4507
|
+
resolve2(result);
|
|
3489
4508
|
};
|
|
3490
4509
|
output.write(question);
|
|
3491
4510
|
rl.on("line", (line) => {
|
|
@@ -3951,7 +4970,7 @@ async function runHandoff(opts) {
|
|
|
3951
4970
|
}
|
|
3952
4971
|
const spawnFn = opts.spawnFn ?? nodeSpawn;
|
|
3953
4972
|
const scoped = await scopedLaunch(
|
|
3954
|
-
{ command: spec.command, args: spec.args, cwd: spec.cwd },
|
|
4973
|
+
{ command: spec.command, args: spec.args, cwd: spec.cwd, env: spec.env },
|
|
3955
4974
|
{ scope: opts.scope },
|
|
3956
4975
|
provider
|
|
3957
4976
|
);
|
|
@@ -3974,20 +4993,20 @@ async function runHandoff(opts) {
|
|
|
3974
4993
|
);
|
|
3975
4994
|
await session.handoffPause();
|
|
3976
4995
|
try {
|
|
3977
|
-
await new Promise((
|
|
4996
|
+
await new Promise((resolve2) => {
|
|
3978
4997
|
const child = spawnFn(scoped.command, scoped.args, {
|
|
3979
4998
|
stdio: "inherit",
|
|
3980
4999
|
cwd: scoped.cwd,
|
|
3981
5000
|
env: buildAcpEnv(process.env, scoped.env ?? void 0)
|
|
3982
5001
|
});
|
|
3983
|
-
child.on("exit", () =>
|
|
5002
|
+
child.on("exit", () => resolve2());
|
|
3984
5003
|
child.on("error", (e) => {
|
|
3985
5004
|
out.write(
|
|
3986
5005
|
`
|
|
3987
5006
|
[handoff] failed to open the session: ${sanitizeForTty(String(e?.message ?? e))}
|
|
3988
5007
|
`
|
|
3989
5008
|
);
|
|
3990
|
-
|
|
5009
|
+
resolve2();
|
|
3991
5010
|
});
|
|
3992
5011
|
});
|
|
3993
5012
|
} finally {
|
|
@@ -4097,19 +5116,19 @@ async function runTakeover(lc, ctx) {
|
|
|
4097
5116
|
`);
|
|
4098
5117
|
return 1;
|
|
4099
5118
|
}
|
|
4100
|
-
const outcome = await new Promise((
|
|
5119
|
+
const outcome = await new Promise((resolve2) => {
|
|
4101
5120
|
let settled = false;
|
|
4102
5121
|
const settle = (o) => {
|
|
4103
5122
|
if (!settled) {
|
|
4104
5123
|
settled = true;
|
|
4105
|
-
|
|
5124
|
+
resolve2(o);
|
|
4106
5125
|
}
|
|
4107
5126
|
};
|
|
4108
5127
|
try {
|
|
4109
5128
|
const child = spawnFn(spec.command, spec.args, {
|
|
4110
5129
|
stdio: "inherit",
|
|
4111
5130
|
cwd: spec.cwd,
|
|
4112
|
-
env: buildAcpEnv(env, spec.env
|
|
5131
|
+
env: buildAcpEnv(env, spec.env ? withOwnGitConfig(spec.env, env) : void 0)
|
|
4113
5132
|
});
|
|
4114
5133
|
child.on("exit", () => settle({}));
|
|
4115
5134
|
child.on("error", (e) => settle({ error: e }));
|
|
@@ -4290,15 +5309,15 @@ function lineClient(sock) {
|
|
|
4290
5309
|
},
|
|
4291
5310
|
drained(timeoutMs) {
|
|
4292
5311
|
if (closed) return Promise.resolve();
|
|
4293
|
-
return new Promise((
|
|
5312
|
+
return new Promise((resolve2) => {
|
|
4294
5313
|
const t = setTimeout(() => {
|
|
4295
5314
|
onClose = null;
|
|
4296
|
-
|
|
5315
|
+
resolve2();
|
|
4297
5316
|
}, timeoutMs);
|
|
4298
5317
|
if (typeof t.unref === "function") t.unref();
|
|
4299
5318
|
onClose = () => {
|
|
4300
5319
|
clearTimeout(t);
|
|
4301
|
-
|
|
5320
|
+
resolve2();
|
|
4302
5321
|
};
|
|
4303
5322
|
});
|
|
4304
5323
|
},
|
|
@@ -4332,6 +5351,13 @@ async function defaultPrompt(stdin, stdout, question) {
|
|
|
4332
5351
|
rl.close();
|
|
4333
5352
|
}
|
|
4334
5353
|
}
|
|
5354
|
+
function withOwnGitConfig(wire, owner) {
|
|
5355
|
+
const rest = {};
|
|
5356
|
+
for (const [k, v] of Object.entries(wire)) {
|
|
5357
|
+
if (k !== "GIT_CONFIG_COUNT" && !/^GIT_CONFIG_(KEY|VALUE)_\d+$/.test(k)) rest[k] = v;
|
|
5358
|
+
}
|
|
5359
|
+
return withAgentGitEnv(rest, owner.GIT_CONFIG_COUNT);
|
|
5360
|
+
}
|
|
4335
5361
|
|
|
4336
5362
|
// src/observe/handoffGate.ts
|
|
4337
5363
|
var HandoffGate = class {
|
|
@@ -4417,13 +5443,13 @@ async function maybeOfferSandboxInstall(deps) {
|
|
|
4417
5443
|
}
|
|
4418
5444
|
function readlinePromptYesNo(input, output) {
|
|
4419
5445
|
return import("readline").then(
|
|
4420
|
-
({ createInterface: createInterface3 }) => new Promise((
|
|
5446
|
+
({ createInterface: createInterface3 }) => new Promise((resolve2) => {
|
|
4421
5447
|
const rl = createInterface3({ input, output });
|
|
4422
5448
|
rl.once("line", (line) => {
|
|
4423
|
-
|
|
5449
|
+
resolve2(line.trim().toLowerCase() === "y");
|
|
4424
5450
|
rl.close();
|
|
4425
5451
|
});
|
|
4426
|
-
rl.once("close", () =>
|
|
5452
|
+
rl.once("close", () => resolve2(false));
|
|
4427
5453
|
})
|
|
4428
5454
|
);
|
|
4429
5455
|
}
|
|
@@ -4645,6 +5671,10 @@ async function applyEndpointGate(cfg, deps, gate, _agentPins, scopeOff = cfg.sco
|
|
|
4645
5671
|
|
|
4646
5672
|
// src/statusVerb.ts
|
|
4647
5673
|
import { homedir as homedir4 } from "os";
|
|
5674
|
+
function gitPosture(e, input) {
|
|
5675
|
+
const g = input.localGitGrant?.(e.agent) ?? null;
|
|
5676
|
+
return g ? { repo: `${g.repoOwner}/${g.repoName}`, mode: g.mode, source: "pat" } : null;
|
|
5677
|
+
}
|
|
4648
5678
|
function endpointRows(e, k, input) {
|
|
4649
5679
|
const derive = input.derive ?? deriveEndpoints;
|
|
4650
5680
|
const derived = derive(k.provider.value, input.env, input.readTextFile);
|
|
@@ -4718,6 +5748,8 @@ function renderStatusHuman(input) {
|
|
|
4718
5748
|
);
|
|
4719
5749
|
const ipcProfile = resolveIpcProfile(e.agent.scope, void 0);
|
|
4720
5750
|
lines.push(` ipcprofile: ${ipcProfile} (${e.agent.scope?.ipcProfile !== void 0 ? "file" : "default"})`);
|
|
5751
|
+
const git = gitPosture(e, input);
|
|
5752
|
+
lines.push(` git : ${git ? `${sanitizeForTty(git.repo)} (${git.mode}, ${git.source})` : "none"}`);
|
|
4721
5753
|
if (e.agent.workspace) lines.push(` workspace: ${sanitizeForTty(e.agent.workspace)}`);
|
|
4722
5754
|
if (e.agent.channelWorkspaces && e.agent.channelWorkspaces.length > 1) {
|
|
4723
5755
|
lines.push(` channels in: ${e.agent.channelWorkspaces.map((w) => sanitizeForTty(w)).join(", ")}`);
|
|
@@ -4754,6 +5786,7 @@ function renderStatusJson(input) {
|
|
|
4754
5786
|
}
|
|
4755
5787
|
},
|
|
4756
5788
|
ipcProfile: { value: ipcProfile, origin: e.agent.scope?.ipcProfile !== void 0 ? "file" : "default" },
|
|
5789
|
+
git: gitPosture(e, input),
|
|
4757
5790
|
endpoints: endpointRows(e, k, input),
|
|
4758
5791
|
// Task 7.1 (D4): null (not "personal"/omitted key) when never cached, so machine
|
|
4759
5792
|
// consumers can distinguish "no data yet" from an actual value.
|
|
@@ -4765,8 +5798,8 @@ function renderStatusJson(input) {
|
|
|
4765
5798
|
}
|
|
4766
5799
|
|
|
4767
5800
|
// src/telemetry.ts
|
|
4768
|
-
import { readFileSync as
|
|
4769
|
-
import { dirname as
|
|
5801
|
+
import { readFileSync as readFileSync9, writeFileSync as writeFileSync7, mkdirSync as mkdirSync7, renameSync as renameSync4, chmodSync as chmodSync6 } from "fs";
|
|
5802
|
+
import { dirname as dirname7, join as join10 } from "path";
|
|
4770
5803
|
var EVENT_PROP_ALLOWLIST = {
|
|
4771
5804
|
tool_mode_selected: ["mode", "surface", "persisted"],
|
|
4772
5805
|
tool_call_blocked: ["tool", "mode", "path", "reason"],
|
|
@@ -4786,11 +5819,11 @@ function isDoNotTrackSet(env) {
|
|
|
4786
5819
|
return /^(1|true|yes)$/i.test(v.trim());
|
|
4787
5820
|
}
|
|
4788
5821
|
function telemetryConfigPath(dir) {
|
|
4789
|
-
return
|
|
5822
|
+
return join10(dir, "telemetry.json");
|
|
4790
5823
|
}
|
|
4791
5824
|
function readTelemetryConfig(dir) {
|
|
4792
5825
|
try {
|
|
4793
|
-
const raw =
|
|
5826
|
+
const raw = readFileSync9(telemetryConfigPath(dir), "utf8");
|
|
4794
5827
|
const parsed = JSON.parse(raw);
|
|
4795
5828
|
if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
4796
5829
|
return parsed;
|
|
@@ -4802,12 +5835,12 @@ function readTelemetryConfig(dir) {
|
|
|
4802
5835
|
}
|
|
4803
5836
|
function writeTelemetryConfig(dir, cfg) {
|
|
4804
5837
|
const file = telemetryConfigPath(dir);
|
|
4805
|
-
|
|
5838
|
+
mkdirSync7(dirname7(file), { recursive: true, mode: 448 });
|
|
4806
5839
|
const tmp = `${file}.tmp-${process.pid}`;
|
|
4807
|
-
|
|
4808
|
-
|
|
5840
|
+
writeFileSync7(tmp, JSON.stringify(cfg, null, 2) + "\n", { mode: 384 });
|
|
5841
|
+
renameSync4(tmp, file);
|
|
4809
5842
|
try {
|
|
4810
|
-
|
|
5843
|
+
chmodSync6(file, 384);
|
|
4811
5844
|
} catch {
|
|
4812
5845
|
}
|
|
4813
5846
|
}
|
|
@@ -4886,12 +5919,12 @@ function renderTelemetryStatusLines(view) {
|
|
|
4886
5919
|
}
|
|
4887
5920
|
|
|
4888
5921
|
// src/service/cli.ts
|
|
4889
|
-
import { execFile } from "child_process";
|
|
5922
|
+
import { execFile as execFile2 } from "child_process";
|
|
4890
5923
|
import { homedir as homedir5 } from "os";
|
|
4891
5924
|
|
|
4892
5925
|
// src/service/manager.ts
|
|
4893
|
-
import { mkdirSync as
|
|
4894
|
-
import { join as
|
|
5926
|
+
import { mkdirSync as mkdirSync9, writeFileSync as writeFileSync9, rmSync as rmSync2, existsSync as existsSync8 } from "fs";
|
|
5927
|
+
import { join as join12 } from "path";
|
|
4895
5928
|
|
|
4896
5929
|
// src/service/templates.ts
|
|
4897
5930
|
function escapeXml(s) {
|
|
@@ -5062,18 +6095,18 @@ function schtasksXml(p) {
|
|
|
5062
6095
|
}
|
|
5063
6096
|
|
|
5064
6097
|
// src/service/win32.ts
|
|
5065
|
-
import { mkdirSync as
|
|
5066
|
-
import { join as
|
|
6098
|
+
import { mkdirSync as mkdirSync8, writeFileSync as writeFileSync8, readdirSync as readdirSync4, readFileSync as readFileSync10, unlinkSync as unlinkSync5 } from "fs";
|
|
6099
|
+
import { join as join11 } from "path";
|
|
5067
6100
|
function win32LogDir(homeDir) {
|
|
5068
|
-
return
|
|
6101
|
+
return join11(homeDir, "AppData", "Local", "braid", "logs");
|
|
5069
6102
|
}
|
|
5070
6103
|
function win32Manager(ctx) {
|
|
5071
|
-
const serviceDir =
|
|
6104
|
+
const serviceDir = join11(ctx.braidDir, "service");
|
|
5072
6105
|
const task = (name) => schtasksLabel(name);
|
|
5073
6106
|
const legacyTask = (name) => legacySchtasksLabel(name);
|
|
5074
|
-
const xmlPath = (name) =>
|
|
5075
|
-
const wrapperPath = (name) =>
|
|
5076
|
-
const launcherPath = (name) =>
|
|
6107
|
+
const xmlPath = (name) => join11(serviceDir, `${task(name)}.xml`);
|
|
6108
|
+
const wrapperPath = (name) => join11(serviceDir, `${task(name)}.cmd`);
|
|
6109
|
+
const launcherPath = (name) => join11(serviceDir, `${task(name)}.vbs`);
|
|
5077
6110
|
const st = (...args) => ctx.exec("schtasks", args);
|
|
5078
6111
|
const stx = async (...args) => {
|
|
5079
6112
|
const res = await st(...args);
|
|
@@ -5084,18 +6117,18 @@ function win32Manager(ctx) {
|
|
|
5084
6117
|
};
|
|
5085
6118
|
const killBridgeProcess = async (name) => {
|
|
5086
6119
|
const legacyAgents = legacyTwinPath(ctx.braidDir, ["agents"]);
|
|
5087
|
-
const agentDirs = [
|
|
6120
|
+
const agentDirs = [join11(ctx.braidDir, "agents"), ...legacyAgents ? [legacyAgents] : []];
|
|
5088
6121
|
const hostDirs = [];
|
|
5089
6122
|
for (const agentsDir of agentDirs) {
|
|
5090
6123
|
try {
|
|
5091
|
-
for (const host of readdirSync4(agentsDir)) hostDirs.push(
|
|
6124
|
+
for (const host of readdirSync4(agentsDir)) hostDirs.push(join11(agentsDir, host));
|
|
5092
6125
|
} catch {
|
|
5093
6126
|
}
|
|
5094
6127
|
}
|
|
5095
6128
|
for (const hostDir of hostDirs) {
|
|
5096
6129
|
let pid = NaN;
|
|
5097
6130
|
try {
|
|
5098
|
-
pid = Number(
|
|
6131
|
+
pid = Number(readFileSync10(join11(hostDir, `${name}.json.lock`), "utf8").trim());
|
|
5099
6132
|
} catch {
|
|
5100
6133
|
continue;
|
|
5101
6134
|
}
|
|
@@ -5111,15 +6144,15 @@ function win32Manager(ctx) {
|
|
|
5111
6144
|
}
|
|
5112
6145
|
};
|
|
5113
6146
|
const write = (name) => {
|
|
5114
|
-
|
|
6147
|
+
mkdirSync8(serviceDir, { recursive: true });
|
|
5115
6148
|
const entryPath = pinnedEntryPath({ braidDir: ctx.braidDir, platform: "win32" });
|
|
5116
6149
|
const cmd = schtasksWrapperCmd({ name, nodePath: ctx.nodePath, entryPath });
|
|
5117
|
-
|
|
6150
|
+
writeFileSync8(wrapperPath(name), cmd);
|
|
5118
6151
|
const vbs = schtasksLauncherVbs({ wrapperPath: wrapperPath(name) });
|
|
5119
|
-
|
|
6152
|
+
writeFileSync8(launcherPath(name), vbs);
|
|
5120
6153
|
const xml = schtasksXml({ name, launcherPath: launcherPath(name) });
|
|
5121
6154
|
const path2 = xmlPath(name);
|
|
5122
|
-
|
|
6155
|
+
writeFileSync8(path2, Buffer.concat([Buffer.from([255, 254]), Buffer.from(xml, "utf16le")]));
|
|
5123
6156
|
return path2;
|
|
5124
6157
|
};
|
|
5125
6158
|
return {
|
|
@@ -5139,13 +6172,13 @@ function win32Manager(ctx) {
|
|
|
5139
6172
|
await st("/delete", "/tn", task(name), "/f");
|
|
5140
6173
|
await st("/delete", "/tn", legacyTask(name), "/f");
|
|
5141
6174
|
try {
|
|
5142
|
-
|
|
6175
|
+
unlinkSync5(join11(runDir(ctx.braidDir), `${name}.token`));
|
|
5143
6176
|
} catch {
|
|
5144
6177
|
}
|
|
5145
6178
|
const legacyToken = legacyTwinPath(ctx.braidDir, ["run", `${name}.token`]);
|
|
5146
6179
|
if (legacyToken) {
|
|
5147
6180
|
try {
|
|
5148
|
-
|
|
6181
|
+
unlinkSync5(legacyToken);
|
|
5149
6182
|
} catch {
|
|
5150
6183
|
}
|
|
5151
6184
|
}
|
|
@@ -5177,7 +6210,7 @@ function win32Manager(ctx) {
|
|
|
5177
6210
|
return { installed, loaded, running };
|
|
5178
6211
|
},
|
|
5179
6212
|
logsPath(name) {
|
|
5180
|
-
return
|
|
6213
|
+
return join11(win32LogDir(ctx.homeDir), `${name}.err.log`);
|
|
5181
6214
|
}
|
|
5182
6215
|
};
|
|
5183
6216
|
}
|
|
@@ -5191,7 +6224,7 @@ var NotSupportedError = class extends Error {
|
|
|
5191
6224
|
};
|
|
5192
6225
|
function makeServiceManager(platform, exec, deps = {}) {
|
|
5193
6226
|
const homeDir = deps.homeDir ?? process.env.HOME ?? "";
|
|
5194
|
-
const braidDir = deps.braidDir ??
|
|
6227
|
+
const braidDir = deps.braidDir ?? join12(homeDir, ".braid");
|
|
5195
6228
|
const uid = deps.uid ?? (typeof process.getuid === "function" ? process.getuid() : 0);
|
|
5196
6229
|
const nodePath = deps.nodePath ?? process.execPath;
|
|
5197
6230
|
const entryPath = pinnedEntryPath({ braidDir });
|
|
@@ -5201,33 +6234,33 @@ function makeServiceManager(platform, exec, deps = {}) {
|
|
|
5201
6234
|
return notSupportedManager(platform);
|
|
5202
6235
|
}
|
|
5203
6236
|
function notSupportedManager(platform) {
|
|
5204
|
-
const
|
|
6237
|
+
const fail2 = () => {
|
|
5205
6238
|
throw new NotSupportedError(`service management is not supported on platform "${platform}"`);
|
|
5206
6239
|
};
|
|
5207
6240
|
return {
|
|
5208
|
-
install: async () =>
|
|
5209
|
-
uninstall: async () =>
|
|
5210
|
-
start: async () =>
|
|
5211
|
-
stop: async () =>
|
|
5212
|
-
restart: async () =>
|
|
5213
|
-
status: async () =>
|
|
5214
|
-
logsPath: () =>
|
|
5215
|
-
templatePath: () =>
|
|
6241
|
+
install: async () => fail2(),
|
|
6242
|
+
uninstall: async () => fail2(),
|
|
6243
|
+
start: async () => fail2(),
|
|
6244
|
+
stop: async () => fail2(),
|
|
6245
|
+
restart: async () => fail2(),
|
|
6246
|
+
status: async () => fail2(),
|
|
6247
|
+
logsPath: () => fail2(),
|
|
6248
|
+
templatePath: () => fail2()
|
|
5216
6249
|
};
|
|
5217
6250
|
}
|
|
5218
6251
|
function darwinManager(ctx) {
|
|
5219
|
-
const launchAgentsDir =
|
|
5220
|
-
const logDir =
|
|
5221
|
-
const legacyLogDir =
|
|
5222
|
-
const plistPath = (name) =>
|
|
5223
|
-
const legacyPlistPath = (name) =>
|
|
5224
|
-
const legacyInstalled = (name) => !
|
|
6252
|
+
const launchAgentsDir = join12(ctx.homeDir, "Library", "LaunchAgents");
|
|
6253
|
+
const logDir = join12(ctx.homeDir, "Library", "Logs", "braid");
|
|
6254
|
+
const legacyLogDir = join12(ctx.homeDir, "Library", "Logs", "arp-bridge");
|
|
6255
|
+
const plistPath = (name) => join12(launchAgentsDir, `${launchdLabel(name)}.plist`);
|
|
6256
|
+
const legacyPlistPath = (name) => join12(launchAgentsDir, `${legacyLaunchdLabel(name)}.plist`);
|
|
6257
|
+
const legacyInstalled = (name) => !existsSync8(plistPath(name)) && existsSync8(legacyPlistPath(name));
|
|
5225
6258
|
const domainTarget = `gui/${ctx.uid}`;
|
|
5226
6259
|
const serviceTarget = (name) => `${domainTarget}/${legacyInstalled(name) ? legacyLaunchdLabel(name) : launchdLabel(name)}`;
|
|
5227
6260
|
const write = (name) => {
|
|
5228
|
-
|
|
5229
|
-
|
|
5230
|
-
|
|
6261
|
+
mkdirSync9(launchAgentsDir, { recursive: true });
|
|
6262
|
+
mkdirSync9(logDir, { recursive: true });
|
|
6263
|
+
writeFileSync9(
|
|
5231
6264
|
plistPath(name),
|
|
5232
6265
|
launchdPlist({ name, nodePath: ctx.nodePath, entryPath: ctx.entryPath, logDir }),
|
|
5233
6266
|
{ mode: 420 }
|
|
@@ -5258,7 +6291,7 @@ function darwinManager(ctx) {
|
|
|
5258
6291
|
}
|
|
5259
6292
|
},
|
|
5260
6293
|
async start(name) {
|
|
5261
|
-
if (!
|
|
6294
|
+
if (!existsSync8(plistPath(name)) && !existsSync8(legacyPlistPath(name))) write(name);
|
|
5262
6295
|
await ctx.exec("launchctl", [
|
|
5263
6296
|
"bootstrap",
|
|
5264
6297
|
domainTarget,
|
|
@@ -5272,7 +6305,7 @@ function darwinManager(ctx) {
|
|
|
5272
6305
|
await ctx.exec("launchctl", ["kickstart", "-k", serviceTarget(name)]);
|
|
5273
6306
|
},
|
|
5274
6307
|
async status(name) {
|
|
5275
|
-
const installed =
|
|
6308
|
+
const installed = existsSync8(plistPath(name)) || existsSync8(legacyPlistPath(name));
|
|
5276
6309
|
if (!installed) return { installed: false, loaded: false, running: false };
|
|
5277
6310
|
const res = await ctx.exec("launchctl", ["print", serviceTarget(name)]);
|
|
5278
6311
|
const loaded = res.code === 0;
|
|
@@ -5281,27 +6314,27 @@ function darwinManager(ctx) {
|
|
|
5281
6314
|
installed,
|
|
5282
6315
|
loaded,
|
|
5283
6316
|
running,
|
|
5284
|
-
staleNodePath: !
|
|
6317
|
+
staleNodePath: !existsSync8(ctx.nodePath)
|
|
5285
6318
|
};
|
|
5286
6319
|
},
|
|
5287
6320
|
logsPath(name) {
|
|
5288
|
-
return
|
|
6321
|
+
return join12(legacyInstalled(name) ? legacyLogDir : logDir, `${name}.err.log`);
|
|
5289
6322
|
}
|
|
5290
6323
|
};
|
|
5291
6324
|
}
|
|
5292
6325
|
function linuxManager(ctx) {
|
|
5293
|
-
const unitDir =
|
|
6326
|
+
const unitDir = join12(ctx.homeDir, ".config", "systemd", "user");
|
|
5294
6327
|
const newUnitName = (name) => `braid-${name}.service`;
|
|
5295
6328
|
const legacyUnitName = (name) => `arp-bridge-${name}.service`;
|
|
5296
|
-
const newUnitPath = (name) =>
|
|
5297
|
-
const legacyUnitPath = (name) =>
|
|
5298
|
-
const legacyInstalled = (name) => !
|
|
6329
|
+
const newUnitPath = (name) => join12(unitDir, newUnitName(name));
|
|
6330
|
+
const legacyUnitPath = (name) => join12(unitDir, legacyUnitName(name));
|
|
6331
|
+
const legacyInstalled = (name) => !existsSync8(newUnitPath(name)) && existsSync8(legacyUnitPath(name));
|
|
5299
6332
|
const unitName = (name) => legacyInstalled(name) ? legacyUnitName(name) : newUnitName(name);
|
|
5300
6333
|
const unitPath = (name) => legacyInstalled(name) ? legacyUnitPath(name) : newUnitPath(name);
|
|
5301
6334
|
const sc = (...args) => ctx.exec("systemctl", ["--user", ...args]);
|
|
5302
6335
|
const write = (name) => {
|
|
5303
|
-
|
|
5304
|
-
|
|
6336
|
+
mkdirSync9(unitDir, { recursive: true });
|
|
6337
|
+
writeFileSync9(
|
|
5305
6338
|
newUnitPath(name),
|
|
5306
6339
|
systemdUnit({ name, nodePath: ctx.nodePath, entryPath: ctx.entryPath }),
|
|
5307
6340
|
{ mode: 420 }
|
|
@@ -5312,7 +6345,7 @@ function linuxManager(ctx) {
|
|
|
5312
6345
|
async install(name) {
|
|
5313
6346
|
write(name);
|
|
5314
6347
|
await sc("daemon-reload");
|
|
5315
|
-
if (
|
|
6348
|
+
if (existsSync8(legacyUnitPath(name))) {
|
|
5316
6349
|
await sc("disable", "--now", legacyUnitName(name));
|
|
5317
6350
|
try {
|
|
5318
6351
|
rmSync2(legacyUnitPath(name), { force: true });
|
|
@@ -5324,7 +6357,7 @@ function linuxManager(ctx) {
|
|
|
5324
6357
|
},
|
|
5325
6358
|
async uninstall(name) {
|
|
5326
6359
|
await sc("disable", "--now", newUnitName(name));
|
|
5327
|
-
if (
|
|
6360
|
+
if (existsSync8(legacyUnitPath(name))) {
|
|
5328
6361
|
await sc("disable", "--now", legacyUnitName(name));
|
|
5329
6362
|
}
|
|
5330
6363
|
try {
|
|
@@ -5338,7 +6371,7 @@ function linuxManager(ctx) {
|
|
|
5338
6371
|
await sc("daemon-reload");
|
|
5339
6372
|
},
|
|
5340
6373
|
async start(name) {
|
|
5341
|
-
if (!
|
|
6374
|
+
if (!existsSync8(unitPath(name))) {
|
|
5342
6375
|
write(name);
|
|
5343
6376
|
await sc("daemon-reload");
|
|
5344
6377
|
}
|
|
@@ -5351,7 +6384,7 @@ function linuxManager(ctx) {
|
|
|
5351
6384
|
await sc("restart", unitName(name));
|
|
5352
6385
|
},
|
|
5353
6386
|
async status(name) {
|
|
5354
|
-
const installed =
|
|
6387
|
+
const installed = existsSync8(unitPath(name));
|
|
5355
6388
|
if (!installed) return { installed: false, loaded: false, running: false };
|
|
5356
6389
|
const active = await sc("is-active", unitName(name));
|
|
5357
6390
|
const enabled = await sc("is-enabled", unitName(name));
|
|
@@ -5361,7 +6394,7 @@ function linuxManager(ctx) {
|
|
|
5361
6394
|
installed,
|
|
5362
6395
|
loaded,
|
|
5363
6396
|
running,
|
|
5364
|
-
staleNodePath: !
|
|
6397
|
+
staleNodePath: !existsSync8(ctx.nodePath)
|
|
5365
6398
|
};
|
|
5366
6399
|
},
|
|
5367
6400
|
logsPath(name) {
|
|
@@ -5371,10 +6404,10 @@ function linuxManager(ctx) {
|
|
|
5371
6404
|
}
|
|
5372
6405
|
|
|
5373
6406
|
// src/service/cli.ts
|
|
5374
|
-
var realExec = (cmd, args) => new Promise((
|
|
5375
|
-
|
|
6407
|
+
var realExec = (cmd, args) => new Promise((resolve2) => {
|
|
6408
|
+
execFile2(cmd, args, { encoding: "utf8" }, (err, stdout, stderr) => {
|
|
5376
6409
|
const code = err && typeof err.code === "number" ? err.code : err ? 1 : 0;
|
|
5377
|
-
|
|
6410
|
+
resolve2({ code, stdout: stdout ?? "", stderr: stderr ?? "" });
|
|
5378
6411
|
});
|
|
5379
6412
|
});
|
|
5380
6413
|
function resolveAgentName(explicit) {
|
|
@@ -5575,17 +6608,17 @@ function printStatusHuman(name, st, logsPath) {
|
|
|
5575
6608
|
}
|
|
5576
6609
|
|
|
5577
6610
|
// src/service/serviceLog.ts
|
|
5578
|
-
import { createWriteStream, mkdirSync as
|
|
5579
|
-
import { dirname as
|
|
6611
|
+
import { createWriteStream, mkdirSync as mkdirSync10 } from "fs";
|
|
6612
|
+
import { dirname as dirname8, join as join13 } from "path";
|
|
5580
6613
|
|
|
5581
6614
|
// src/service/logRotation.ts
|
|
5582
6615
|
import {
|
|
5583
|
-
existsSync as
|
|
5584
|
-
statSync as
|
|
5585
|
-
renameSync as
|
|
6616
|
+
existsSync as existsSync9,
|
|
6617
|
+
statSync as statSync3,
|
|
6618
|
+
renameSync as renameSync5,
|
|
5586
6619
|
rmSync as rmSync3,
|
|
5587
|
-
readFileSync as
|
|
5588
|
-
writeFileSync as
|
|
6620
|
+
readFileSync as readFileSync11,
|
|
6621
|
+
writeFileSync as writeFileSync10
|
|
5589
6622
|
} from "fs";
|
|
5590
6623
|
import { gzipSync } from "zlib";
|
|
5591
6624
|
var DEFAULT_MAX_BYTES = 10 * 1024 * 1024;
|
|
@@ -5594,33 +6627,33 @@ function rotateLogFileIfNeeded(logPath, opts = {}) {
|
|
|
5594
6627
|
const maxBytes = opts.maxBytes ?? DEFAULT_MAX_BYTES;
|
|
5595
6628
|
const keepCount = opts.keepCount ?? DEFAULT_KEEP_COUNT;
|
|
5596
6629
|
try {
|
|
5597
|
-
if (!
|
|
6630
|
+
if (!existsSync9(logPath)) return;
|
|
5598
6631
|
let size;
|
|
5599
6632
|
try {
|
|
5600
|
-
size =
|
|
6633
|
+
size = statSync3(logPath).size;
|
|
5601
6634
|
} catch {
|
|
5602
6635
|
return;
|
|
5603
6636
|
}
|
|
5604
6637
|
if (size <= maxBytes) return;
|
|
5605
6638
|
const gz = (n) => `${logPath}.${n}.gz`;
|
|
5606
6639
|
try {
|
|
5607
|
-
if (
|
|
6640
|
+
if (existsSync9(gz(keepCount))) rmSync3(gz(keepCount), { force: true });
|
|
5608
6641
|
} catch {
|
|
5609
6642
|
}
|
|
5610
6643
|
for (let n = keepCount - 1; n >= 1; n--) {
|
|
5611
6644
|
try {
|
|
5612
|
-
if (
|
|
6645
|
+
if (existsSync9(gz(n))) renameSync5(gz(n), gz(n + 1));
|
|
5613
6646
|
} catch {
|
|
5614
6647
|
}
|
|
5615
6648
|
}
|
|
5616
6649
|
try {
|
|
5617
|
-
const raw =
|
|
5618
|
-
|
|
6650
|
+
const raw = readFileSync11(logPath);
|
|
6651
|
+
writeFileSync10(gz(1), gzipSync(raw));
|
|
5619
6652
|
} catch {
|
|
5620
6653
|
return;
|
|
5621
6654
|
}
|
|
5622
6655
|
try {
|
|
5623
|
-
|
|
6656
|
+
writeFileSync10(logPath, "");
|
|
5624
6657
|
} catch {
|
|
5625
6658
|
}
|
|
5626
6659
|
} catch {
|
|
@@ -5629,16 +6662,16 @@ function rotateLogFileIfNeeded(logPath, opts = {}) {
|
|
|
5629
6662
|
|
|
5630
6663
|
// src/service/serviceLog.ts
|
|
5631
6664
|
function darwinLogDir(homeDir) {
|
|
5632
|
-
return
|
|
6665
|
+
return join13(homeDir, "Library", "Logs", "braid");
|
|
5633
6666
|
}
|
|
5634
6667
|
function serviceLogTargets(name, platform, homeDir) {
|
|
5635
6668
|
if (platform === "darwin") {
|
|
5636
6669
|
const d = darwinLogDir(homeDir);
|
|
5637
|
-
return [
|
|
6670
|
+
return [join13(d, `${name}.out.log`), join13(d, `${name}.err.log`)];
|
|
5638
6671
|
}
|
|
5639
6672
|
if (platform === "win32") {
|
|
5640
6673
|
const d = win32LogDir(homeDir);
|
|
5641
|
-
return [
|
|
6674
|
+
return [join13(d, `${name}.out.log`), join13(d, `${name}.err.log`)];
|
|
5642
6675
|
}
|
|
5643
6676
|
return [];
|
|
5644
6677
|
}
|
|
@@ -5688,7 +6721,7 @@ function initServiceLogging(opts) {
|
|
|
5688
6721
|
};
|
|
5689
6722
|
}
|
|
5690
6723
|
function defaultTeeStream(path2) {
|
|
5691
|
-
|
|
6724
|
+
mkdirSync10(dirname8(path2), { recursive: true });
|
|
5692
6725
|
const stream = createWriteStream(path2, { flags: "a" });
|
|
5693
6726
|
stream.on("error", () => {
|
|
5694
6727
|
});
|
|
@@ -5828,12 +6861,13 @@ function printStatus(agentName, json) {
|
|
|
5828
6861
|
}
|
|
5829
6862
|
const readTextFile = (p) => {
|
|
5830
6863
|
try {
|
|
5831
|
-
return
|
|
6864
|
+
return readFileSync12(p, "utf8");
|
|
5832
6865
|
} catch {
|
|
5833
6866
|
return null;
|
|
5834
6867
|
}
|
|
5835
6868
|
};
|
|
5836
|
-
const
|
|
6869
|
+
const localGitGrant = (a) => loadLocalGitGrant(dir, a.relayUrl, a.agentName);
|
|
6870
|
+
const input = { entries, env: process.env, readTextFile, localGitGrant };
|
|
5837
6871
|
if (json) {
|
|
5838
6872
|
console.log(renderStatusJson(input));
|
|
5839
6873
|
return;
|
|
@@ -5896,19 +6930,84 @@ function setIpcProfile(profile, agentName) {
|
|
|
5896
6930
|
);
|
|
5897
6931
|
}
|
|
5898
6932
|
}
|
|
6933
|
+
async function gitAuthSet(inv) {
|
|
6934
|
+
const dir = configDir(process.env);
|
|
6935
|
+
const entry = loadAgent(dir, inv.agent, "Run: braid list").agent;
|
|
6936
|
+
if (process.stdin.isTTY === true) {
|
|
6937
|
+
console.error(
|
|
6938
|
+
"[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"
|
|
6939
|
+
);
|
|
6940
|
+
return 1;
|
|
6941
|
+
}
|
|
6942
|
+
let token;
|
|
6943
|
+
try {
|
|
6944
|
+
token = (await readBoundedStdin(process.stdin, 4096, "token")).trim();
|
|
6945
|
+
} catch (err) {
|
|
6946
|
+
console.error(`[braid] git-auth set failed: ${sanitizeForTty(err instanceof Error ? err.message : String(err))}`);
|
|
6947
|
+
return 1;
|
|
6948
|
+
}
|
|
6949
|
+
if (token === "") {
|
|
6950
|
+
console.error("[braid] git-auth set received an empty token on stdin. Pipe the token in and retry.");
|
|
6951
|
+
return 1;
|
|
6952
|
+
}
|
|
6953
|
+
saveLocalGitGrant(dir, entry.relayUrl, entry.agentName, {
|
|
6954
|
+
source: "pat",
|
|
6955
|
+
repoOwner: inv.repoOwner,
|
|
6956
|
+
repoName: inv.repoName,
|
|
6957
|
+
mode: inv.mode,
|
|
6958
|
+
allowWorkflows: inv.allowWorkflows,
|
|
6959
|
+
allowRiskPaths: inv.allowRiskPaths,
|
|
6960
|
+
allowPublic: inv.allowPublic,
|
|
6961
|
+
token
|
|
6962
|
+
});
|
|
6963
|
+
console.log(
|
|
6964
|
+
`[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.`
|
|
6965
|
+
);
|
|
6966
|
+
return 0;
|
|
6967
|
+
}
|
|
6968
|
+
function gitAuthShow(agentName) {
|
|
6969
|
+
const dir = configDir(process.env);
|
|
6970
|
+
const entry = loadAgent(dir, agentName, "Run: braid list").agent;
|
|
6971
|
+
const name = sanitizeForTty(entry.agentName);
|
|
6972
|
+
const g = loadLocalGitGrant(dir, entry.relayUrl, entry.agentName);
|
|
6973
|
+
if (!g) {
|
|
6974
|
+
console.log(`[braid] ${name}: no local git grant.`);
|
|
6975
|
+
return;
|
|
6976
|
+
}
|
|
6977
|
+
console.log(`[braid] ${name}: local git grant`);
|
|
6978
|
+
console.log(` repo : ${sanitizeForTty(`${g.repoOwner}/${g.repoName}`)}`);
|
|
6979
|
+
console.log(` mode : ${g.mode}`);
|
|
6980
|
+
console.log(` allowWorkflows : ${g.allowWorkflows}`);
|
|
6981
|
+
console.log(` allowRiskPaths : ${g.allowRiskPaths}`);
|
|
6982
|
+
console.log(` allowPublic : ${g.allowPublic}`);
|
|
6983
|
+
console.log(" source: pat");
|
|
6984
|
+
}
|
|
6985
|
+
function gitAuthClear(agentName) {
|
|
6986
|
+
const dir = configDir(process.env);
|
|
6987
|
+
const entry = loadAgent(dir, agentName, "Run: braid list").agent;
|
|
6988
|
+
const name = sanitizeForTty(entry.agentName);
|
|
6989
|
+
if (clearLocalGitGrant(dir, entry.relayUrl, entry.agentName)) {
|
|
6990
|
+
console.log(`[braid] ${name}: local git grant removed. Restart the bridge for the change to take effect.`);
|
|
6991
|
+
} else {
|
|
6992
|
+
console.log(`[braid] ${name}: nothing to clear, no local git grant was set.`);
|
|
6993
|
+
}
|
|
6994
|
+
}
|
|
5899
6995
|
function printScope(agentName) {
|
|
5900
6996
|
const dir = configDir(process.env);
|
|
5901
6997
|
let scope;
|
|
5902
6998
|
let pinnedEndpoints;
|
|
6999
|
+
let resolved;
|
|
5903
7000
|
if (agentName !== void 0) {
|
|
5904
7001
|
const entry = loadAgent(dir, agentName, "Run: braid scope <name>").agent;
|
|
5905
7002
|
scope = entry.scope;
|
|
5906
7003
|
pinnedEndpoints = entry.pinnedEndpoints;
|
|
7004
|
+
resolved = entry;
|
|
5907
7005
|
} else {
|
|
5908
7006
|
try {
|
|
5909
7007
|
const entry = loadAgent(dir).agent;
|
|
5910
7008
|
scope = entry.scope;
|
|
5911
7009
|
pinnedEndpoints = entry.pinnedEndpoints;
|
|
7010
|
+
resolved = entry;
|
|
5912
7011
|
} catch {
|
|
5913
7012
|
}
|
|
5914
7013
|
}
|
|
@@ -5917,9 +7016,15 @@ function printScope(agentName) {
|
|
|
5917
7016
|
for (const line of lines) {
|
|
5918
7017
|
console.log(line);
|
|
5919
7018
|
}
|
|
7019
|
+
if (resolved !== void 0) {
|
|
7020
|
+
const g = loadLocalGitGrant(dir, resolved.relayUrl, resolved.agentName);
|
|
7021
|
+
console.log(
|
|
7022
|
+
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)"
|
|
7023
|
+
);
|
|
7024
|
+
}
|
|
5920
7025
|
const readTextFile = (p) => {
|
|
5921
7026
|
try {
|
|
5922
|
-
return
|
|
7027
|
+
return readFileSync12(p, "utf8");
|
|
5923
7028
|
} catch {
|
|
5924
7029
|
return null;
|
|
5925
7030
|
}
|
|
@@ -5959,7 +7064,7 @@ function scopeAllow(agentName, host) {
|
|
|
5959
7064
|
const { agent: provider } = resolveAgentSelection(process.env);
|
|
5960
7065
|
const readTextFile = (p) => {
|
|
5961
7066
|
try {
|
|
5962
|
-
return
|
|
7067
|
+
return readFileSync12(p, "utf8");
|
|
5963
7068
|
} catch {
|
|
5964
7069
|
return null;
|
|
5965
7070
|
}
|
|
@@ -5993,6 +7098,13 @@ Set the provider's config to use ${normalizedHost} and try again, or check BRAID
|
|
|
5993
7098
|
console.log(`[braid] pinned: ${normalizedHost} (source: ${match.source})`);
|
|
5994
7099
|
console.log(`[braid] restart the bridge for the change to take effect.`);
|
|
5995
7100
|
}
|
|
7101
|
+
function scopeAllowCodeHosts(agentName) {
|
|
7102
|
+
const dir = configDir(process.env);
|
|
7103
|
+
setScopeAllowCodeHosts(dir, agentName, true);
|
|
7104
|
+
console.log(`[braid] ${agentName}: direct code-host egress is now ON (github.com, gitlab.com, bitbucket.org reachable from the jail).`);
|
|
7105
|
+
console.log(`[braid] This re-opens an exfiltration path. Prefer the Braid git tools; undo by editing scope.network.allowCodeHosts in the saved agent file.`);
|
|
7106
|
+
console.log(`[braid] restart the bridge for the change to take effect.`);
|
|
7107
|
+
}
|
|
5996
7108
|
function runTelemetryVerb(verb) {
|
|
5997
7109
|
const dir = configDir(process.env);
|
|
5998
7110
|
if (verb === "disable") {
|
|
@@ -6063,6 +7175,21 @@ async function main() {
|
|
|
6063
7175
|
scopeAllow(invocation.agent, invocation.host);
|
|
6064
7176
|
return;
|
|
6065
7177
|
}
|
|
7178
|
+
if (invocation.kind === "scope-allow-code-hosts") {
|
|
7179
|
+
scopeAllowCodeHosts(invocation.agent);
|
|
7180
|
+
return;
|
|
7181
|
+
}
|
|
7182
|
+
if (invocation.kind === "git-auth-set") {
|
|
7183
|
+
process.exit(await gitAuthSet(invocation));
|
|
7184
|
+
}
|
|
7185
|
+
if (invocation.kind === "git-auth-show") {
|
|
7186
|
+
gitAuthShow(invocation.agent);
|
|
7187
|
+
return;
|
|
7188
|
+
}
|
|
7189
|
+
if (invocation.kind === "git-auth-clear") {
|
|
7190
|
+
gitAuthClear(invocation.agent);
|
|
7191
|
+
return;
|
|
7192
|
+
}
|
|
6066
7193
|
if (invocation.kind === "telemetry") {
|
|
6067
7194
|
runTelemetryVerb(invocation.verb);
|
|
6068
7195
|
return;
|
|
@@ -6213,7 +7340,7 @@ async function main() {
|
|
|
6213
7340
|
} catch {
|
|
6214
7341
|
}
|
|
6215
7342
|
}
|
|
6216
|
-
assertKeystorePerms(dir, file);
|
|
7343
|
+
assertKeystorePerms(dir, file, void 0, cfg.agentName ? [localGitGrantPath(dir, cfg.relayWsUrl, cfg.agentName)] : []);
|
|
6217
7344
|
}
|
|
6218
7345
|
console.log(banner("braid", "STARTING", startingLines(cfg, {
|
|
6219
7346
|
...cfg.agentMode === "acp" && cfg.agent === "claude-code" ? { claudeBinary: describeClaudeBinary(resolveClaudeBinary()) } : {}
|
|
@@ -6234,7 +7361,7 @@ async function main() {
|
|
|
6234
7361
|
const epScopeOff = process.env.BRAID_SCOPE === "off" || cfg.scope?.disabled === true;
|
|
6235
7362
|
const readTextFile = (p) => {
|
|
6236
7363
|
try {
|
|
6237
|
-
return
|
|
7364
|
+
return readFileSync12(p, "utf8");
|
|
6238
7365
|
} catch {
|
|
6239
7366
|
return null;
|
|
6240
7367
|
}
|
|
@@ -6264,7 +7391,7 @@ async function main() {
|
|
|
6264
7391
|
cancelled = true;
|
|
6265
7392
|
rl.close();
|
|
6266
7393
|
});
|
|
6267
|
-
const eofGuard = new Promise((
|
|
7394
|
+
const eofGuard = new Promise((resolve2) => rl.once("close", () => resolve2(false)));
|
|
6268
7395
|
try {
|
|
6269
7396
|
const answer = await Promise.race([
|
|
6270
7397
|
rl.question(prompt).then((a) => a.trim().toLowerCase() === "y"),
|