@bondsmith/bondsmithd 0.1.3 → 0.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +454 -150
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -15,7 +15,7 @@ var REPLAYED_COMMAND_MESSAGE = "command already used (replay rejected)";
|
|
|
15
15
|
var INVALID_MESSAGE_ERROR = "invalid message";
|
|
16
16
|
|
|
17
17
|
// ../../packages/local-agent-protocol/src/protocol-version.ts
|
|
18
|
-
var PROTOCOL_VERSION =
|
|
18
|
+
var PROTOCOL_VERSION = 4;
|
|
19
19
|
|
|
20
20
|
// ../../packages/local-agent-protocol/src/project-path.ts
|
|
21
21
|
var PROJECT_PATH_INVALID_REASONS = [
|
|
@@ -57,6 +57,13 @@ var signedConnectionChallengeSchema = z.object({
|
|
|
57
57
|
|
|
58
58
|
// ../../packages/local-agent-protocol/src/schemas.ts
|
|
59
59
|
var MAX_INSERT_TEXT_BYTES = 32 * 1024;
|
|
60
|
+
var MAX_INPUT_CHARS = 16 * 1024;
|
|
61
|
+
var MAX_WIRE_MESSAGE_BYTES = 256 * 1024;
|
|
62
|
+
var MAX_TERMINAL_DIMENSION = 1e3;
|
|
63
|
+
var terminalSizeSchema = z2.object({
|
|
64
|
+
cols: z2.number().int().min(1).max(MAX_TERMINAL_DIMENSION),
|
|
65
|
+
rows: z2.number().int().min(1).max(MAX_TERMINAL_DIMENSION)
|
|
66
|
+
});
|
|
60
67
|
var localSessionSchema = z2.object({
|
|
61
68
|
id: z2.string(),
|
|
62
69
|
title: z2.string(),
|
|
@@ -83,6 +90,8 @@ var workspaceFileEntrySchema = z2.object({
|
|
|
83
90
|
var workspaceStatusSchema = z2.object({
|
|
84
91
|
isGitRepo: z2.boolean(),
|
|
85
92
|
branch: z2.string().nullable(),
|
|
93
|
+
// .catch(undefined): a malformed informational field must never drop the whole status message.
|
|
94
|
+
baseRef: z2.string().nullable().optional().catch(void 0),
|
|
86
95
|
files: z2.array(workspaceFileEntrySchema)
|
|
87
96
|
});
|
|
88
97
|
var workspaceFileDiffSchema = z2.object({
|
|
@@ -128,12 +137,15 @@ var browserMessageSchema = z2.discriminatedUnion("type", [
|
|
|
128
137
|
// misattributes a daemon error for ANY other in-flight command
|
|
129
138
|
// (observed: a session.resize racing ahead of its own session.attach)
|
|
130
139
|
// to the wrong promise — a confirmed bug, not a hypothetical.
|
|
131
|
-
requestId: z2.string().min(1).max(200).optional()
|
|
140
|
+
requestId: z2.string().min(1).max(200).optional(),
|
|
141
|
+
// Tolerant like baseRef: an unreadable size must not make the daemon drop the attach itself.
|
|
142
|
+
size: terminalSizeSchema.optional().catch(void 0)
|
|
132
143
|
}),
|
|
144
|
+
z2.object({ type: z2.literal("session.detach"), sessionId: z2.string() }),
|
|
133
145
|
z2.object({
|
|
134
146
|
type: z2.literal("session.input"),
|
|
135
147
|
sessionId: z2.string(),
|
|
136
|
-
data: z2.string()
|
|
148
|
+
data: z2.string().max(MAX_INPUT_CHARS)
|
|
137
149
|
}),
|
|
138
150
|
z2.object({
|
|
139
151
|
type: z2.literal("session.insertText"),
|
|
@@ -143,8 +155,7 @@ var browserMessageSchema = z2.discriminatedUnion("type", [
|
|
|
143
155
|
z2.object({
|
|
144
156
|
type: z2.literal("session.resize"),
|
|
145
157
|
sessionId: z2.string(),
|
|
146
|
-
|
|
147
|
-
rows: z2.number().int().positive()
|
|
158
|
+
...terminalSizeSchema.shape
|
|
148
159
|
}),
|
|
149
160
|
z2.object({ type: z2.literal("session.stop"), sessionId: z2.string() }),
|
|
150
161
|
z2.object({
|
|
@@ -348,6 +359,7 @@ import { z as z3 } from "zod";
|
|
|
348
359
|
var SIGNABLE_ACTIONS = [
|
|
349
360
|
"session.create",
|
|
350
361
|
"session.attach",
|
|
362
|
+
"session.detach",
|
|
351
363
|
"session.input",
|
|
352
364
|
"session.insertText",
|
|
353
365
|
"session.resize",
|
|
@@ -838,6 +850,8 @@ function expandHome(cwd) {
|
|
|
838
850
|
import { execFile as execFileCb } from "child_process";
|
|
839
851
|
import { promisify } from "util";
|
|
840
852
|
var execFile = promisify(execFileCb);
|
|
853
|
+
var MAX_GIT_OUTPUT_BYTES = 64 * 1024 * 1024;
|
|
854
|
+
var SAFE_DIFF_FLAGS = ["--no-ext-diff", "--no-textconv"];
|
|
841
855
|
var GitNotARepoError = class extends Error {
|
|
842
856
|
constructor() {
|
|
843
857
|
super("not a git repository");
|
|
@@ -851,7 +865,7 @@ function isNotARepoError(error2) {
|
|
|
851
865
|
}
|
|
852
866
|
async function runGit(args, cwd) {
|
|
853
867
|
try {
|
|
854
|
-
const { stdout } = await execFile("git", args, { cwd });
|
|
868
|
+
const { stdout } = await execFile("git", args, { cwd, maxBuffer: MAX_GIT_OUTPUT_BYTES });
|
|
855
869
|
return stdout;
|
|
856
870
|
} catch (error2) {
|
|
857
871
|
if (isNotARepoError(error2)) throw new GitNotARepoError();
|
|
@@ -860,7 +874,7 @@ async function runGit(args, cwd) {
|
|
|
860
874
|
}
|
|
861
875
|
async function runGitAllowingExitCodes(args, cwd, allowedExitCodes) {
|
|
862
876
|
try {
|
|
863
|
-
const { stdout } = await execFile("git", args, { cwd });
|
|
877
|
+
const { stdout } = await execFile("git", args, { cwd, maxBuffer: MAX_GIT_OUTPUT_BYTES });
|
|
864
878
|
return stdout;
|
|
865
879
|
} catch (error2) {
|
|
866
880
|
if (isNotARepoError(error2)) throw new GitNotARepoError();
|
|
@@ -872,6 +886,12 @@ async function runGitAllowingExitCodes(args, cwd, allowedExitCodes) {
|
|
|
872
886
|
throw error2;
|
|
873
887
|
}
|
|
874
888
|
}
|
|
889
|
+
function runGitDiff(args, cwd) {
|
|
890
|
+
return runGit(["diff", ...SAFE_DIFF_FLAGS, ...args], cwd);
|
|
891
|
+
}
|
|
892
|
+
function runGitDiffAllowingExitCodes(args, cwd, allowedExitCodes) {
|
|
893
|
+
return runGitAllowingExitCodes(["diff", ...SAFE_DIFF_FLAGS, ...args], cwd, allowedExitCodes);
|
|
894
|
+
}
|
|
875
895
|
|
|
876
896
|
// src/projects/validate-path.ts
|
|
877
897
|
var NOT_FOUND_CODES = /* @__PURE__ */ new Set(["ENOENT", "ENOTDIR"]);
|
|
@@ -883,6 +903,27 @@ function invalidReasonForFsError(error2) {
|
|
|
883
903
|
if (PERMISSION_CODES.has(code)) return "permission_denied";
|
|
884
904
|
return null;
|
|
885
905
|
}
|
|
906
|
+
function shellQuote(value) {
|
|
907
|
+
return `'${value.replaceAll("'", `'\\''`)}'`;
|
|
908
|
+
}
|
|
909
|
+
function gitFailure(error2, path) {
|
|
910
|
+
const { code, stderr } = error2;
|
|
911
|
+
if (code === "ENOENT") {
|
|
912
|
+
return new Error(
|
|
913
|
+
"git isn't installed, or isn't on the PATH of the terminal running bondsmithd."
|
|
914
|
+
);
|
|
915
|
+
}
|
|
916
|
+
const text = typeof stderr === "string" ? stderr : "";
|
|
917
|
+
if (text.includes("dubious ownership")) {
|
|
918
|
+
return new Error(
|
|
919
|
+
`git doesn't trust this folder because another user owns it. If you trust it, run: git config --global --add safe.directory ${shellQuote(path)}`
|
|
920
|
+
);
|
|
921
|
+
}
|
|
922
|
+
const fatal = text.split("\n").find((line) => line.startsWith("fatal:"));
|
|
923
|
+
return new Error(
|
|
924
|
+
fatal ?? (error2 instanceof Error ? error2.message : "git failed to inspect that folder.")
|
|
925
|
+
);
|
|
926
|
+
}
|
|
886
927
|
async function validateProjectPath(input) {
|
|
887
928
|
const expanded = expandHome(input.trim());
|
|
888
929
|
if (!isAbsolute(expanded)) return { valid: false, reason: "relative_path" };
|
|
@@ -900,7 +941,7 @@ async function validateProjectPath(input) {
|
|
|
900
941
|
if (insideWorkTree !== "true") return { valid: false, reason: "not_a_git_repo" };
|
|
901
942
|
} catch (error2) {
|
|
902
943
|
if (error2 instanceof GitNotARepoError) return { valid: false, reason: "not_a_git_repo" };
|
|
903
|
-
throw error2;
|
|
944
|
+
throw gitFailure(error2, path);
|
|
904
945
|
}
|
|
905
946
|
return { valid: true, path };
|
|
906
947
|
}
|
|
@@ -910,7 +951,14 @@ import { sign } from "crypto";
|
|
|
910
951
|
import WebSocket from "ws";
|
|
911
952
|
|
|
912
953
|
// src/version.ts
|
|
913
|
-
var DAEMON_VERSION = true ? "0.1.
|
|
954
|
+
var DAEMON_VERSION = true ? "0.1.5" : "dev";
|
|
955
|
+
var VERSION_FLAGS = ["--version", "-v"];
|
|
956
|
+
function versionLine() {
|
|
957
|
+
return `bondsmithd ${DAEMON_VERSION} (protocol ${PROTOCOL_VERSION})`;
|
|
958
|
+
}
|
|
959
|
+
function isVersionRequest(argv) {
|
|
960
|
+
return argv.some((arg) => VERSION_FLAGS.includes(arg));
|
|
961
|
+
}
|
|
914
962
|
|
|
915
963
|
// src/sessions/tmux/client.ts
|
|
916
964
|
import { execFile as execFileCb2 } from "child_process";
|
|
@@ -982,9 +1030,18 @@ async function listTmuxSessions(formatString) {
|
|
|
982
1030
|
throw error2;
|
|
983
1031
|
}
|
|
984
1032
|
}
|
|
1033
|
+
var NO_SERVER_MESSAGE_PATTERNS = [
|
|
1034
|
+
/no server running/i,
|
|
1035
|
+
/no current session/i,
|
|
1036
|
+
/no sessions/i,
|
|
1037
|
+
/failed to connect to server/i,
|
|
1038
|
+
/error connecting to .*\((no such file or directory|connection refused)\)/i
|
|
1039
|
+
];
|
|
985
1040
|
function isNoServerError(error2) {
|
|
986
|
-
if (typeof error2 !== "object" || error2 === null) return
|
|
987
|
-
|
|
1041
|
+
if (typeof error2 !== "object" || error2 === null) return false;
|
|
1042
|
+
const { message, stderr } = error2;
|
|
1043
|
+
const text = [message, stderr].filter((part) => typeof part === "string").join("\n");
|
|
1044
|
+
return NO_SERVER_MESSAGE_PATTERNS.some((pattern) => pattern.test(text));
|
|
988
1045
|
}
|
|
989
1046
|
|
|
990
1047
|
// src/security/verify-command.ts
|
|
@@ -1165,6 +1222,76 @@ async function verifyCommand(command, daemonFingerprint, connectionState, truste
|
|
|
1165
1222
|
return { ok: true };
|
|
1166
1223
|
}
|
|
1167
1224
|
|
|
1225
|
+
// src/ws/output-pacer.ts
|
|
1226
|
+
var OUTPUT_HIGH_WATER_BYTES = 1024 * 1024;
|
|
1227
|
+
var OUTPUT_LOW_WATER_BYTES = 256 * 1024;
|
|
1228
|
+
var DRAIN_POLL_MS = 50;
|
|
1229
|
+
var MAX_CAPTURE_FAILURES = 5;
|
|
1230
|
+
var REPAINT_PREFIX = "\x1B[0m\x1B[2J\x1B[H";
|
|
1231
|
+
function createOutputPacer(options) {
|
|
1232
|
+
const highWater = options.highWaterBytes ?? OUTPUT_HIGH_WATER_BYTES;
|
|
1233
|
+
const lowWater = options.lowWaterBytes ?? OUTPUT_LOW_WATER_BYTES;
|
|
1234
|
+
const pollMs = options.pollMs ?? DRAIN_POLL_MS;
|
|
1235
|
+
let behind = false;
|
|
1236
|
+
let disposed = false;
|
|
1237
|
+
let captureFailures = 0;
|
|
1238
|
+
let timer;
|
|
1239
|
+
function scheduleRepaint() {
|
|
1240
|
+
timer = setTimeout(() => void repaintWhenDrained(), pollMs);
|
|
1241
|
+
}
|
|
1242
|
+
async function repaintWhenDrained() {
|
|
1243
|
+
timer = void 0;
|
|
1244
|
+
if (disposed) return;
|
|
1245
|
+
if (options.bufferedBytes() > lowWater) {
|
|
1246
|
+
scheduleRepaint();
|
|
1247
|
+
return;
|
|
1248
|
+
}
|
|
1249
|
+
let snapshot;
|
|
1250
|
+
try {
|
|
1251
|
+
snapshot = await options.snapshot();
|
|
1252
|
+
} catch (error2) {
|
|
1253
|
+
if (disposed) return;
|
|
1254
|
+
captureFailures += 1;
|
|
1255
|
+
if (captureFailures < MAX_CAPTURE_FAILURES) {
|
|
1256
|
+
scheduleRepaint();
|
|
1257
|
+
return;
|
|
1258
|
+
}
|
|
1259
|
+
console.warn("bondsmithd: giving up repainting a terminal after repeated capture failures", error2);
|
|
1260
|
+
behind = false;
|
|
1261
|
+
captureFailures = 0;
|
|
1262
|
+
return;
|
|
1263
|
+
}
|
|
1264
|
+
if (disposed) return;
|
|
1265
|
+
captureFailures = 0;
|
|
1266
|
+
if (options.bufferedBytes() > lowWater) {
|
|
1267
|
+
scheduleRepaint();
|
|
1268
|
+
return;
|
|
1269
|
+
}
|
|
1270
|
+
try {
|
|
1271
|
+
options.send(REPAINT_PREFIX + snapshot);
|
|
1272
|
+
} catch (error2) {
|
|
1273
|
+
console.error("bondsmithd: terminal repaint failed", error2);
|
|
1274
|
+
}
|
|
1275
|
+
behind = false;
|
|
1276
|
+
}
|
|
1277
|
+
return {
|
|
1278
|
+
write(data) {
|
|
1279
|
+
if (disposed || behind) return;
|
|
1280
|
+
if (options.bufferedBytes() > highWater) {
|
|
1281
|
+
behind = true;
|
|
1282
|
+
scheduleRepaint();
|
|
1283
|
+
return;
|
|
1284
|
+
}
|
|
1285
|
+
options.send(data);
|
|
1286
|
+
},
|
|
1287
|
+
dispose() {
|
|
1288
|
+
disposed = true;
|
|
1289
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
1290
|
+
timer = void 0;
|
|
1291
|
+
}
|
|
1292
|
+
};
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1168
1295
|
// src/ws/router.ts
|
|
1169
1296
|
var SIGNABLE_ACTION_SET = new Set(SIGNABLE_ACTIONS);
|
|
1170
1297
|
function isSignableAction(type) {
|
|
@@ -1191,17 +1318,31 @@ function verifyRejectionMessage(reason) {
|
|
|
1191
1318
|
var REVOKED_REJECTION_MESSAGE = "connection has not completed its handshake";
|
|
1192
1319
|
var BRACKETED_PASTE_START = "\x1B[200~";
|
|
1193
1320
|
var BRACKETED_PASTE_END = "\x1B[201~";
|
|
1194
|
-
function
|
|
1321
|
+
function releaseSession(conn, ptyAttachments, sessionId) {
|
|
1322
|
+
const listener = conn.getOutputListener(sessionId);
|
|
1323
|
+
if (listener) ptyAttachments.release(sessionId, listener);
|
|
1324
|
+
conn.unregisterOutputListener(sessionId);
|
|
1325
|
+
conn.subscriptions.delete(sessionId);
|
|
1326
|
+
}
|
|
1327
|
+
function createRouterConnection(connectionAuth, send, markControllerId, ptyAttachments, bufferedBytes) {
|
|
1195
1328
|
const outputListeners = /* @__PURE__ */ new Map();
|
|
1196
1329
|
const attachLocks = /* @__PURE__ */ new Map();
|
|
1330
|
+
let closed = false;
|
|
1197
1331
|
let admissionChain = Promise.resolve();
|
|
1198
1332
|
const conn = {
|
|
1199
1333
|
subscriptions: /* @__PURE__ */ new Set(),
|
|
1200
1334
|
send,
|
|
1201
|
-
|
|
1202
|
-
|
|
1335
|
+
bufferedBytes,
|
|
1336
|
+
isClosed: () => closed,
|
|
1337
|
+
registerOutputListener: (sessionId, listener, dispose) => {
|
|
1338
|
+
outputListeners.get(sessionId)?.dispose?.();
|
|
1339
|
+
outputListeners.set(sessionId, { listener, dispose });
|
|
1340
|
+
},
|
|
1341
|
+
getOutputListener: (sessionId) => outputListeners.get(sessionId)?.listener,
|
|
1342
|
+
unregisterOutputListener: (sessionId) => {
|
|
1343
|
+
outputListeners.get(sessionId)?.dispose?.();
|
|
1344
|
+
outputListeners.delete(sessionId);
|
|
1203
1345
|
},
|
|
1204
|
-
getOutputListener: (sessionId) => outputListeners.get(sessionId),
|
|
1205
1346
|
connectionAuth,
|
|
1206
1347
|
markControllerId,
|
|
1207
1348
|
admitInOrder: (fn) => {
|
|
@@ -1224,9 +1365,9 @@ function createRouterConnection(connectionAuth, send, markControllerId, ptyAttac
|
|
|
1224
1365
|
}
|
|
1225
1366
|
};
|
|
1226
1367
|
function releaseAllSubscriptions() {
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1368
|
+
closed = true;
|
|
1369
|
+
for (const sessionId of [...conn.subscriptions]) {
|
|
1370
|
+
releaseSession(conn, ptyAttachments, sessionId);
|
|
1230
1371
|
}
|
|
1231
1372
|
}
|
|
1232
1373
|
return { conn, releaseAllSubscriptions };
|
|
@@ -1237,9 +1378,12 @@ function error(message, requestId) {
|
|
|
1237
1378
|
function errorMessageOf(err, fallback) {
|
|
1238
1379
|
return err instanceof Error ? err.message : fallback;
|
|
1239
1380
|
}
|
|
1240
|
-
function
|
|
1381
|
+
function requestIdOf(message) {
|
|
1382
|
+
return "requestId" in message ? message.requestId : void 0;
|
|
1383
|
+
}
|
|
1384
|
+
function getSessionOrError(sessionManager, conn, sessionId, requestId) {
|
|
1241
1385
|
const session = sessionManager.get(sessionId);
|
|
1242
|
-
if (!session) conn.send(error("unknown session"));
|
|
1386
|
+
if (!session) conn.send(error("unknown session", requestId));
|
|
1243
1387
|
return session;
|
|
1244
1388
|
}
|
|
1245
1389
|
async function handleBrowserMessage(conn, raw, deps) {
|
|
@@ -1259,11 +1403,11 @@ async function handleBrowserMessage(conn, raw, deps) {
|
|
|
1259
1403
|
deps.trustedControllersPath
|
|
1260
1404
|
);
|
|
1261
1405
|
if (!verdict.ok) {
|
|
1262
|
-
conn.send(error(verifyRejectionMessage(verdict.reason)));
|
|
1406
|
+
conn.send(error(verifyRejectionMessage(verdict.reason), requestIdOf(innerMessage.data)));
|
|
1263
1407
|
return false;
|
|
1264
1408
|
}
|
|
1265
1409
|
if (conn.connectionAuth.isRevoked()) {
|
|
1266
|
-
conn.send(error(REVOKED_REJECTION_MESSAGE));
|
|
1410
|
+
conn.send(error(REVOKED_REJECTION_MESSAGE, requestIdOf(innerMessage.data)));
|
|
1267
1411
|
return false;
|
|
1268
1412
|
}
|
|
1269
1413
|
return true;
|
|
@@ -1291,7 +1435,7 @@ async function handleBrowserMessage(conn, raw, deps) {
|
|
|
1291
1435
|
return;
|
|
1292
1436
|
}
|
|
1293
1437
|
if (isSignableAction(parsed.data.type)) {
|
|
1294
|
-
conn.send(error("signature required for this command"));
|
|
1438
|
+
conn.send(error("signature required for this command", requestIdOf(parsed.data)));
|
|
1295
1439
|
return;
|
|
1296
1440
|
}
|
|
1297
1441
|
await dispatch(conn, parsed.data, deps);
|
|
@@ -1336,8 +1480,17 @@ async function dispatch(conn, message, {
|
|
|
1336
1480
|
}
|
|
1337
1481
|
await conn.withAttachLock(message.sessionId, async () => {
|
|
1338
1482
|
const previousListener = conn.getOutputListener(message.sessionId);
|
|
1339
|
-
const
|
|
1340
|
-
conn.send({ type: "terminal.output", sessionId: message.sessionId, data })
|
|
1483
|
+
const output = createOutputPacer({
|
|
1484
|
+
send: (data) => conn.send({ type: "terminal.output", sessionId: message.sessionId, data }),
|
|
1485
|
+
bufferedBytes: conn.bufferedBytes,
|
|
1486
|
+
snapshot: () => ptyAttachments.paneSnapshot(session.tmuxSessionName)
|
|
1487
|
+
});
|
|
1488
|
+
const onData = output.write;
|
|
1489
|
+
const abandonedBecauseClosed = () => {
|
|
1490
|
+
if (!conn.isClosed()) return false;
|
|
1491
|
+
ptyAttachments.release(message.sessionId, onData);
|
|
1492
|
+
output.dispose();
|
|
1493
|
+
return true;
|
|
1341
1494
|
};
|
|
1342
1495
|
if (previousListener && await ptyAttachments.swapListener(
|
|
1343
1496
|
message.sessionId,
|
|
@@ -1345,29 +1498,41 @@ async function dispatch(conn, message, {
|
|
|
1345
1498
|
previousListener,
|
|
1346
1499
|
onData
|
|
1347
1500
|
)) {
|
|
1501
|
+
if (abandonedBecauseClosed()) return;
|
|
1348
1502
|
conn.subscriptions.add(message.sessionId);
|
|
1349
|
-
conn.registerOutputListener(message.sessionId, onData);
|
|
1503
|
+
conn.registerOutputListener(message.sessionId, onData, output.dispose);
|
|
1350
1504
|
conn.send({ type: "session.attached", sessionId: message.sessionId, requestId });
|
|
1351
1505
|
return;
|
|
1352
1506
|
}
|
|
1353
1507
|
if (previousListener) {
|
|
1354
1508
|
ptyAttachments.release(message.sessionId, previousListener);
|
|
1509
|
+
conn.unregisterOutputListener(message.sessionId);
|
|
1355
1510
|
}
|
|
1356
1511
|
const attached = await ptyAttachments.ensureAttached(
|
|
1357
1512
|
message.sessionId,
|
|
1358
1513
|
session.tmuxSessionName,
|
|
1359
|
-
onData
|
|
1514
|
+
onData,
|
|
1515
|
+
message.size
|
|
1360
1516
|
);
|
|
1361
1517
|
if (!attached) {
|
|
1518
|
+
output.dispose();
|
|
1362
1519
|
conn.send(error("failed to attach to session", requestId));
|
|
1363
1520
|
return;
|
|
1364
1521
|
}
|
|
1522
|
+
if (abandonedBecauseClosed()) return;
|
|
1365
1523
|
conn.subscriptions.add(message.sessionId);
|
|
1366
|
-
conn.registerOutputListener(message.sessionId, onData);
|
|
1524
|
+
conn.registerOutputListener(message.sessionId, onData, output.dispose);
|
|
1367
1525
|
conn.send({ type: "session.attached", sessionId: message.sessionId, requestId });
|
|
1368
1526
|
});
|
|
1369
1527
|
return;
|
|
1370
1528
|
}
|
|
1529
|
+
case "session.detach": {
|
|
1530
|
+
if (!isValidSessionId(message.sessionId)) return;
|
|
1531
|
+
await conn.withAttachLock(message.sessionId, async () => {
|
|
1532
|
+
releaseSession(conn, ptyAttachments, message.sessionId);
|
|
1533
|
+
});
|
|
1534
|
+
return;
|
|
1535
|
+
}
|
|
1371
1536
|
case "session.input": {
|
|
1372
1537
|
if (!conn.subscriptions.has(message.sessionId)) {
|
|
1373
1538
|
conn.send(error("not attached to session"));
|
|
@@ -1409,7 +1574,7 @@ async function dispatch(conn, message, {
|
|
|
1409
1574
|
return;
|
|
1410
1575
|
}
|
|
1411
1576
|
case "workspace.getStatus": {
|
|
1412
|
-
const session = getSessionOrError(sessionManager, conn, message.sessionId);
|
|
1577
|
+
const session = getSessionOrError(sessionManager, conn, message.sessionId, message.requestId);
|
|
1413
1578
|
if (!session) return;
|
|
1414
1579
|
try {
|
|
1415
1580
|
const status = await getWorkspaceStatus2(session.cwd);
|
|
@@ -1420,12 +1585,12 @@ async function dispatch(conn, message, {
|
|
|
1420
1585
|
status
|
|
1421
1586
|
});
|
|
1422
1587
|
} catch (err) {
|
|
1423
|
-
conn.send(error(errorMessageOf(err, "failed to read workspace status")));
|
|
1588
|
+
conn.send(error(errorMessageOf(err, "failed to read workspace status"), message.requestId));
|
|
1424
1589
|
}
|
|
1425
1590
|
return;
|
|
1426
1591
|
}
|
|
1427
1592
|
case "workspace.getDiff": {
|
|
1428
|
-
const session = getSessionOrError(sessionManager, conn, message.sessionId);
|
|
1593
|
+
const session = getSessionOrError(sessionManager, conn, message.sessionId, message.requestId);
|
|
1429
1594
|
if (!session) return;
|
|
1430
1595
|
try {
|
|
1431
1596
|
const diff = await getWorkspaceDiff2(session.cwd, message.path, message.staged);
|
|
@@ -1436,7 +1601,7 @@ async function dispatch(conn, message, {
|
|
|
1436
1601
|
diff
|
|
1437
1602
|
});
|
|
1438
1603
|
} catch (err) {
|
|
1439
|
-
conn.send(error(errorMessageOf(err, "failed to read workspace diff")));
|
|
1604
|
+
conn.send(error(errorMessageOf(err, "failed to read workspace diff"), message.requestId));
|
|
1440
1605
|
}
|
|
1441
1606
|
return;
|
|
1442
1607
|
}
|
|
@@ -1771,7 +1936,9 @@ function startRelayClient(deps) {
|
|
|
1771
1936
|
},
|
|
1772
1937
|
() => {
|
|
1773
1938
|
},
|
|
1774
|
-
deps.ptyAttachments
|
|
1939
|
+
deps.ptyAttachments,
|
|
1940
|
+
// One socket carries every browser session, so a backed-up relay link slows all of them together.
|
|
1941
|
+
() => ws.bufferedAmount
|
|
1775
1942
|
);
|
|
1776
1943
|
}
|
|
1777
1944
|
function handleSessionOpened(ws, relaySessionId) {
|
|
@@ -1999,12 +2166,27 @@ async function persistToken(tokenPath, token) {
|
|
|
1999
2166
|
|
|
2000
2167
|
// src/security/pending-authorizations.ts
|
|
2001
2168
|
import { createHash, randomUUID } from "crypto";
|
|
2169
|
+
|
|
2170
|
+
// src/security/controller-label.ts
|
|
2171
|
+
var MAX_LABEL_CHARACTERS = 60;
|
|
2172
|
+
var MAX_STACKED_MARKS = 3;
|
|
2173
|
+
var FALLBACK_LABEL = "Unnamed browser";
|
|
2174
|
+
var EXCESS_MARKS = new RegExp(`(\\p{M}{${MAX_STACKED_MARKS}})\\p{M}+`, "gu");
|
|
2175
|
+
function sanitizeControllerLabel(raw) {
|
|
2176
|
+
const printable = raw.replace(/\p{C}/gu, " ").replace(EXCESS_MARKS, "$1").replace(/\s+/g, " ").trim();
|
|
2177
|
+
const clipped = Array.from(printable).slice(0, MAX_LABEL_CHARACTERS).join("").trim();
|
|
2178
|
+
return clipped === "" ? FALLBACK_LABEL : clipped;
|
|
2179
|
+
}
|
|
2180
|
+
|
|
2181
|
+
// src/security/pending-authorizations.ts
|
|
2002
2182
|
function controllerIdFromPublicKeySync(publicKeyPem) {
|
|
2003
2183
|
return createHash("sha256").update(publicKeyPem).digest("hex").slice(0, 32);
|
|
2004
2184
|
}
|
|
2005
2185
|
var REQUEST_EXPIRY_MS = 5 * 6e4;
|
|
2006
2186
|
var MAX_REQUESTS_PER_WINDOW = 5;
|
|
2007
2187
|
var RATE_LIMIT_WINDOW_MS = 6e4;
|
|
2188
|
+
var MAX_PENDING_REQUESTS = 5;
|
|
2189
|
+
var MAX_NEW_REQUESTS_PER_WINDOW = 10;
|
|
2008
2190
|
var PendingAuthorizationStore = class {
|
|
2009
2191
|
// Invariant: at most one PendingEntry per controllerId at any time. A
|
|
2010
2192
|
// conflicting requestEnrollment call for a controllerId that already has
|
|
@@ -2015,6 +2197,7 @@ var PendingAuthorizationStore = class {
|
|
|
2015
2197
|
// resolve a newer, unrelated one.
|
|
2016
2198
|
pending = /* @__PURE__ */ new Map();
|
|
2017
2199
|
requestTimestamps = /* @__PURE__ */ new Map();
|
|
2200
|
+
allRequestTimestamps = [];
|
|
2018
2201
|
isInteractive;
|
|
2019
2202
|
trustedControllersPath;
|
|
2020
2203
|
constructor(options = {}) {
|
|
@@ -2060,7 +2243,7 @@ var PendingAuthorizationStore = class {
|
|
|
2060
2243
|
result: existing.resultPromise
|
|
2061
2244
|
};
|
|
2062
2245
|
}
|
|
2063
|
-
if (this.isRateLimited(controllerId)) {
|
|
2246
|
+
if (this.isRateLimited(controllerId) || this.isOverGlobalLimits()) {
|
|
2064
2247
|
return { ok: false, reason: "rate_limited" };
|
|
2065
2248
|
}
|
|
2066
2249
|
this.recordRequestTimestamp(controllerId);
|
|
@@ -2068,7 +2251,7 @@ var PendingAuthorizationStore = class {
|
|
|
2068
2251
|
const authorization = {
|
|
2069
2252
|
controllerId,
|
|
2070
2253
|
publicKeyPem: params.publicKeyPem,
|
|
2071
|
-
label: params.label,
|
|
2254
|
+
label: sanitizeControllerLabel(params.label),
|
|
2072
2255
|
fingerprint: "",
|
|
2073
2256
|
requestedAt: now,
|
|
2074
2257
|
expiresAt: now + REQUEST_EXPIRY_MS,
|
|
@@ -2156,19 +2339,33 @@ var PendingAuthorizationStore = class {
|
|
|
2156
2339
|
entry.settle("cancelled");
|
|
2157
2340
|
}
|
|
2158
2341
|
}
|
|
2342
|
+
/** True while this exact request is still waiting for an answer, so a queued prompt can skip one that expired or was cancelled. */
|
|
2343
|
+
isPending(controllerId, requestToken) {
|
|
2344
|
+
const entry = this.pending.get(controllerId);
|
|
2345
|
+
return entry !== void 0 && !entry.settled && entry.authorization.requestToken === requestToken && entry.authorization.expiresAt > Date.now();
|
|
2346
|
+
}
|
|
2159
2347
|
isRateLimited(controllerId) {
|
|
2160
2348
|
const timestamps = this.requestTimestamps.get(controllerId) ?? [];
|
|
2161
2349
|
const windowStart = Date.now() - RATE_LIMIT_WINDOW_MS;
|
|
2162
2350
|
const recent = timestamps.filter((t) => t > windowStart);
|
|
2163
2351
|
return recent.length >= MAX_REQUESTS_PER_WINDOW;
|
|
2164
2352
|
}
|
|
2165
|
-
|
|
2353
|
+
isOverGlobalLimits() {
|
|
2166
2354
|
const windowStart = Date.now() - RATE_LIMIT_WINDOW_MS;
|
|
2167
|
-
const
|
|
2168
|
-
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
|
|
2355
|
+
const recent = this.allRequestTimestamps.filter((t) => t > windowStart);
|
|
2356
|
+
return recent.length >= MAX_NEW_REQUESTS_PER_WINDOW || this.pending.size >= MAX_PENDING_REQUESTS;
|
|
2357
|
+
}
|
|
2358
|
+
recordRequestTimestamp(controllerId) {
|
|
2359
|
+
const now = Date.now();
|
|
2360
|
+
const windowStart = now - RATE_LIMIT_WINDOW_MS;
|
|
2361
|
+
this.allRequestTimestamps = this.allRequestTimestamps.filter((t) => t > windowStart);
|
|
2362
|
+
this.allRequestTimestamps.push(now);
|
|
2363
|
+
for (const [key, timestamps] of this.requestTimestamps) {
|
|
2364
|
+
if (!timestamps.some((t) => t > windowStart)) this.requestTimestamps.delete(key);
|
|
2365
|
+
}
|
|
2366
|
+
const recent = (this.requestTimestamps.get(controllerId) ?? []).filter((t) => t > windowStart);
|
|
2367
|
+
recent.push(now);
|
|
2368
|
+
this.requestTimestamps.set(controllerId, recent);
|
|
2172
2369
|
}
|
|
2173
2370
|
};
|
|
2174
2371
|
|
|
@@ -2280,42 +2477,101 @@ function startRevocationSync(deps) {
|
|
|
2280
2477
|
// src/security/terminal-approval.ts
|
|
2281
2478
|
import { createInterface } from "readline";
|
|
2282
2479
|
function presentPendingAuthorizationOnTerminal(store) {
|
|
2283
|
-
|
|
2480
|
+
const waiting = [];
|
|
2481
|
+
let prompting = false;
|
|
2482
|
+
let inputEnded = false;
|
|
2483
|
+
const record = (pending, approved) => {
|
|
2484
|
+
store.resolve(pending.controllerId, pending.requestToken, approved).catch((error2) => {
|
|
2485
|
+
console.error("Failed to record controller authorization decision", error2);
|
|
2486
|
+
});
|
|
2487
|
+
};
|
|
2488
|
+
const askNext = () => {
|
|
2489
|
+
if (prompting) return;
|
|
2490
|
+
const pending = waiting.shift();
|
|
2491
|
+
if (!pending) return;
|
|
2492
|
+
if (!store.isPending(pending.controllerId, pending.requestToken)) {
|
|
2493
|
+
askNext();
|
|
2494
|
+
return;
|
|
2495
|
+
}
|
|
2496
|
+
if (inputEnded) {
|
|
2497
|
+
record(pending, false);
|
|
2498
|
+
askNext();
|
|
2499
|
+
return;
|
|
2500
|
+
}
|
|
2501
|
+
prompting = true;
|
|
2284
2502
|
console.log("");
|
|
2285
2503
|
console.log(" Bondsmith controller authorization request");
|
|
2286
|
-
console.log(` Browser: ${pending.label}`);
|
|
2504
|
+
console.log(` Browser: "${pending.label}"`);
|
|
2287
2505
|
console.log(` Fingerprint: ${pending.fingerprint}`);
|
|
2506
|
+
if (waiting.length > 0) console.log(` (${waiting.length} more waiting after this one)`);
|
|
2288
2507
|
console.log("");
|
|
2289
2508
|
console.log(" Allow this browser to control local sessions? [y/N]");
|
|
2290
2509
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
2291
|
-
|
|
2510
|
+
let answered = false;
|
|
2511
|
+
let expired = false;
|
|
2512
|
+
const expiryTimer = setTimeout(
|
|
2513
|
+
() => {
|
|
2514
|
+
expired = true;
|
|
2515
|
+
rl.close();
|
|
2516
|
+
},
|
|
2517
|
+
Math.max(0, pending.expiresAt - Date.now())
|
|
2518
|
+
);
|
|
2519
|
+
const finish = (approved) => {
|
|
2520
|
+
if (answered) return;
|
|
2521
|
+
answered = true;
|
|
2522
|
+
clearTimeout(expiryTimer);
|
|
2292
2523
|
rl.close();
|
|
2293
|
-
|
|
2294
|
-
|
|
2295
|
-
|
|
2296
|
-
});
|
|
2297
|
-
console.log(approved ? " Approved." : " Denied.");
|
|
2524
|
+
record(pending, approved);
|
|
2525
|
+
if (expired) console.log(" Request expired.");
|
|
2526
|
+
else console.log(approved ? " Approved." : " Denied.");
|
|
2298
2527
|
console.log("");
|
|
2528
|
+
prompting = false;
|
|
2529
|
+
askNext();
|
|
2530
|
+
};
|
|
2531
|
+
rl.question(" > ", (answer) => finish(answer.trim().toLowerCase() === "y"));
|
|
2532
|
+
rl.on("SIGINT", () => finish(false));
|
|
2533
|
+
rl.on("close", () => {
|
|
2534
|
+
if (!answered && !expired) inputEnded = true;
|
|
2535
|
+
finish(false);
|
|
2299
2536
|
});
|
|
2300
2537
|
};
|
|
2538
|
+
return (pending) => {
|
|
2539
|
+
waiting.push(pending);
|
|
2540
|
+
askNext();
|
|
2541
|
+
};
|
|
2301
2542
|
}
|
|
2302
2543
|
|
|
2303
|
-
// src/sessions/git/
|
|
2304
|
-
|
|
2305
|
-
|
|
2306
|
-
|
|
2307
|
-
|
|
2308
|
-
|
|
2309
|
-
|
|
2544
|
+
// src/sessions/git/base.ts
|
|
2545
|
+
var EMPTY_TREE_SHA = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
|
|
2546
|
+
var DEFAULT_BRANCH_CANDIDATES = ["origin/main", "origin/master", "main", "master"];
|
|
2547
|
+
async function tryGit(args, cwd) {
|
|
2548
|
+
try {
|
|
2549
|
+
return (await runGit(args, cwd)).trim() || null;
|
|
2550
|
+
} catch {
|
|
2551
|
+
return null;
|
|
2310
2552
|
}
|
|
2311
|
-
|
|
2312
|
-
|
|
2313
|
-
|
|
2314
|
-
|
|
2315
|
-
|
|
2316
|
-
|
|
2553
|
+
}
|
|
2554
|
+
async function findDefaultBranchRef(cwd) {
|
|
2555
|
+
const remoteHead = await tryGit(
|
|
2556
|
+
["symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD"],
|
|
2557
|
+
cwd
|
|
2558
|
+
);
|
|
2559
|
+
if (remoteHead) return remoteHead;
|
|
2560
|
+
for (const candidate of DEFAULT_BRANCH_CANDIDATES) {
|
|
2561
|
+
if (await tryGit(["rev-parse", "--verify", "--quiet", `${candidate}^{commit}`], cwd)) {
|
|
2562
|
+
return candidate;
|
|
2563
|
+
}
|
|
2317
2564
|
}
|
|
2318
|
-
return
|
|
2565
|
+
return null;
|
|
2566
|
+
}
|
|
2567
|
+
async function resolveComparisonBase(cwd) {
|
|
2568
|
+
if (!await tryGit(["rev-parse", "--verify", "--quiet", "HEAD^{commit}"], cwd)) {
|
|
2569
|
+
return { sha: EMPTY_TREE_SHA, ref: null };
|
|
2570
|
+
}
|
|
2571
|
+
const defaultRef = await findDefaultBranchRef(cwd);
|
|
2572
|
+
if (!defaultRef) return { sha: "HEAD", ref: null };
|
|
2573
|
+
const mergeBase = await tryGit(["merge-base", "HEAD", defaultRef], cwd);
|
|
2574
|
+
return mergeBase ? { sha: mergeBase, ref: defaultRef } : { sha: "HEAD", ref: null };
|
|
2319
2575
|
}
|
|
2320
2576
|
|
|
2321
2577
|
// src/sessions/git/status.ts
|
|
@@ -2325,7 +2581,11 @@ async function getCurrentBranch(cwd) {
|
|
|
2325
2581
|
return out === "" || out === "HEAD" ? null : out;
|
|
2326
2582
|
} catch (err) {
|
|
2327
2583
|
if (err instanceof GitNotARepoError) return null;
|
|
2328
|
-
|
|
2584
|
+
try {
|
|
2585
|
+
return (await runGit(["symbolic-ref", "--quiet", "--short", "HEAD"], cwd)).trim() || null;
|
|
2586
|
+
} catch {
|
|
2587
|
+
throw err;
|
|
2588
|
+
}
|
|
2329
2589
|
}
|
|
2330
2590
|
}
|
|
2331
2591
|
async function getRepositoryFullName(cwd) {
|
|
@@ -2375,88 +2635,73 @@ function parseNumstat(output) {
|
|
|
2375
2635
|
}
|
|
2376
2636
|
return map;
|
|
2377
2637
|
}
|
|
2378
|
-
var
|
|
2638
|
+
var STATUS_BY_LETTER = {
|
|
2379
2639
|
A: "added",
|
|
2380
2640
|
M: "modified",
|
|
2381
2641
|
D: "deleted",
|
|
2382
2642
|
R: "renamed",
|
|
2383
2643
|
C: "copied",
|
|
2644
|
+
T: "modified",
|
|
2645
|
+
// type change, such as a file becoming a symlink
|
|
2384
2646
|
U: "modified"
|
|
2385
|
-
// unmerged
|
|
2647
|
+
// unmerged: no conflict-resolution UI yet, so it reads as modified
|
|
2386
2648
|
};
|
|
2387
|
-
|
|
2388
|
-
|
|
2389
|
-
|
|
2390
|
-
|
|
2391
|
-
|
|
2392
|
-
const
|
|
2393
|
-
runGit(["status", "--porcelain=v1", "-z"], cwd),
|
|
2394
|
-
runGit(["diff", "--numstat", "-z"], cwd),
|
|
2395
|
-
runGit(["diff", "--cached", "--numstat", "-z"], cwd)
|
|
2396
|
-
]);
|
|
2397
|
-
const unstagedNumstat = parseNumstat(unstagedRaw);
|
|
2398
|
-
const stagedNumstat = parseNumstat(stagedRaw);
|
|
2399
|
-
const files = parsePorcelain(porcelain, unstagedNumstat, stagedNumstat);
|
|
2400
|
-
return { isGitRepo: true, branch, files };
|
|
2401
|
-
}
|
|
2402
|
-
function parsePorcelain(porcelain, unstagedNumstat, stagedNumstat) {
|
|
2403
|
-
const entries = [];
|
|
2404
|
-
const fields = porcelain.split("\0").filter((f) => f.length > 0);
|
|
2649
|
+
function isStatusLetter(letter) {
|
|
2650
|
+
return letter in STATUS_BY_LETTER;
|
|
2651
|
+
}
|
|
2652
|
+
function parseNameStatus(output) {
|
|
2653
|
+
const fields = output.split("\0").filter((f) => f.length > 0);
|
|
2654
|
+
const changed = [];
|
|
2405
2655
|
let i = 0;
|
|
2406
2656
|
while (i < fields.length) {
|
|
2407
|
-
const
|
|
2408
|
-
const
|
|
2409
|
-
|
|
2410
|
-
|
|
2411
|
-
|
|
2412
|
-
|
|
2413
|
-
|
|
2414
|
-
|
|
2415
|
-
|
|
2416
|
-
|
|
2417
|
-
|
|
2657
|
+
const letter = fields[i++][0] ?? "";
|
|
2658
|
+
const status = isStatusLetter(letter) ? STATUS_BY_LETTER[letter] : "modified";
|
|
2659
|
+
if (status === "renamed" || status === "copied") {
|
|
2660
|
+
const previousPath = fields[i++];
|
|
2661
|
+
const path = fields[i++];
|
|
2662
|
+
if (previousPath === void 0 || path === void 0) break;
|
|
2663
|
+
changed.push({ path, previousPath, status });
|
|
2664
|
+
} else {
|
|
2665
|
+
const path = fields[i++];
|
|
2666
|
+
if (path === void 0) break;
|
|
2667
|
+
changed.push({ path, status });
|
|
2418
2668
|
}
|
|
2419
|
-
pushEntriesForPath(entries, path, x, y, void 0, unstagedNumstat, stagedNumstat);
|
|
2420
2669
|
}
|
|
2421
|
-
return
|
|
2670
|
+
return changed;
|
|
2422
2671
|
}
|
|
2423
|
-
function
|
|
2424
|
-
|
|
2425
|
-
|
|
2426
|
-
|
|
2427
|
-
path,
|
|
2428
|
-
status: "untracked",
|
|
2429
|
-
staged: false,
|
|
2430
|
-
additions: numstat?.additions ?? null,
|
|
2431
|
-
deletions: numstat?.deletions ?? null,
|
|
2432
|
-
binary: numstat?.binary ?? false
|
|
2433
|
-
});
|
|
2434
|
-
return;
|
|
2435
|
-
}
|
|
2436
|
-
if (x !== " " && x !== "?") {
|
|
2437
|
-
const numstat = stagedNumstat.get(path);
|
|
2438
|
-
entries.push({
|
|
2439
|
-
path,
|
|
2440
|
-
previousPath,
|
|
2441
|
-
status: STATUS_CODE_MAP[x] ?? "modified",
|
|
2442
|
-
staged: true,
|
|
2443
|
-
additions: numstat?.additions ?? null,
|
|
2444
|
-
deletions: numstat?.deletions ?? null,
|
|
2445
|
-
binary: numstat?.binary ?? false
|
|
2446
|
-
});
|
|
2672
|
+
async function getWorkspaceStatus(cwd) {
|
|
2673
|
+
const branch = await getCurrentBranch(cwd);
|
|
2674
|
+
if (branch === null && !await isGitRepo(cwd)) {
|
|
2675
|
+
return { isGitRepo: false, branch: null, files: [] };
|
|
2447
2676
|
}
|
|
2448
|
-
|
|
2449
|
-
|
|
2450
|
-
|
|
2451
|
-
|
|
2452
|
-
|
|
2453
|
-
|
|
2677
|
+
const base = await resolveComparisonBase(cwd);
|
|
2678
|
+
const [nameStatusRaw, numstatRaw, untrackedRaw] = await Promise.all([
|
|
2679
|
+
runGitDiff(["--name-status", "-z", "-M", base.sha], cwd),
|
|
2680
|
+
runGitDiff(["--numstat", "-z", "-M", base.sha], cwd),
|
|
2681
|
+
runGit(["ls-files", "--others", "--exclude-standard", "-z"], cwd)
|
|
2682
|
+
]);
|
|
2683
|
+
const numstat = parseNumstat(numstatRaw);
|
|
2684
|
+
const tracked = parseNameStatus(nameStatusRaw).map((changed) => {
|
|
2685
|
+
const counts = numstat.get(changed.path);
|
|
2686
|
+
return {
|
|
2687
|
+
path: changed.path,
|
|
2688
|
+
previousPath: changed.previousPath,
|
|
2689
|
+
status: changed.status,
|
|
2454
2690
|
staged: false,
|
|
2455
|
-
additions:
|
|
2456
|
-
deletions:
|
|
2457
|
-
binary:
|
|
2458
|
-
}
|
|
2459
|
-
}
|
|
2691
|
+
additions: counts?.additions ?? null,
|
|
2692
|
+
deletions: counts?.deletions ?? null,
|
|
2693
|
+
binary: counts?.binary ?? false
|
|
2694
|
+
};
|
|
2695
|
+
});
|
|
2696
|
+
const untracked = untrackedRaw.split("\0").filter((path) => path.length > 0).map((path) => ({
|
|
2697
|
+
path,
|
|
2698
|
+
status: "untracked",
|
|
2699
|
+
staged: false,
|
|
2700
|
+
additions: null,
|
|
2701
|
+
deletions: null,
|
|
2702
|
+
binary: false
|
|
2703
|
+
}));
|
|
2704
|
+
return { isGitRepo: true, branch, baseRef: base.ref, files: [...tracked, ...untracked] };
|
|
2460
2705
|
}
|
|
2461
2706
|
async function enrichSessionWithGitMetadata(session) {
|
|
2462
2707
|
const [branch, repositoryFullName, status, lastActivityAt] = await Promise.all([
|
|
@@ -2475,6 +2720,33 @@ async function enrichSessionWithGitMetadata(session) {
|
|
|
2475
2720
|
};
|
|
2476
2721
|
}
|
|
2477
2722
|
|
|
2723
|
+
// src/sessions/git/diff.ts
|
|
2724
|
+
async function withRenameSource(cwd, baseSha, path) {
|
|
2725
|
+
const nameStatus = await runGitDiff(["--name-status", "-z", "-M", baseSha], cwd);
|
|
2726
|
+
const previousPath = parseNameStatus(nameStatus).find((c) => c.path === path)?.previousPath;
|
|
2727
|
+
return previousPath === void 0 ? [path] : [previousPath, path];
|
|
2728
|
+
}
|
|
2729
|
+
async function isUntracked(cwd, path) {
|
|
2730
|
+
const out = await runGit(["ls-files", "--others", "--exclude-standard", "--", path], cwd);
|
|
2731
|
+
return out.trim() !== "";
|
|
2732
|
+
}
|
|
2733
|
+
async function getWorkspaceDiff(cwd, path, staged) {
|
|
2734
|
+
const base = await resolveComparisonBase(cwd);
|
|
2735
|
+
const pathspec = await withRenameSource(cwd, base.sha, path);
|
|
2736
|
+
const numstat = await runGitDiff(["--numstat", "-M", base.sha, "--", ...pathspec], cwd);
|
|
2737
|
+
const binary = /^(-\t-\t)/.test(numstat.trim());
|
|
2738
|
+
if (binary) {
|
|
2739
|
+
return { path, staged, patch: null, binary: true };
|
|
2740
|
+
}
|
|
2741
|
+
let patch = await runGitDiff(["-M", base.sha, "--", ...pathspec], cwd);
|
|
2742
|
+
if (patch.trim() === "" && await isUntracked(cwd, path)) {
|
|
2743
|
+
patch = await runGitDiffAllowingExitCodes(["--no-index", "--", "/dev/null", path], cwd, [
|
|
2744
|
+
1
|
|
2745
|
+
]).catch(() => "");
|
|
2746
|
+
}
|
|
2747
|
+
return { path, staged, patch: patch || null, binary: false };
|
|
2748
|
+
}
|
|
2749
|
+
|
|
2478
2750
|
// src/ws/server.ts
|
|
2479
2751
|
import { hostname } from "os";
|
|
2480
2752
|
import { WebSocketServer } from "ws";
|
|
@@ -2500,23 +2772,40 @@ function isAllowedOrigin(origin, allowlist) {
|
|
|
2500
2772
|
|
|
2501
2773
|
// src/sessions/pty-attachment.ts
|
|
2502
2774
|
import * as pty from "node-pty";
|
|
2775
|
+
var DEFAULT_PTY_SIZE = { cols: 80, rows: 24 };
|
|
2776
|
+
var MAX_SNAPSHOT_JSON_BYTES = 128 * 1024;
|
|
2777
|
+
function keepBottomRows(rows) {
|
|
2778
|
+
let budget = MAX_SNAPSHOT_JSON_BYTES;
|
|
2779
|
+
let first = rows.length;
|
|
2780
|
+
while (first > 0) {
|
|
2781
|
+
budget -= JSON.stringify(rows[first - 1]).length;
|
|
2782
|
+
if (budget < 0) break;
|
|
2783
|
+
first -= 1;
|
|
2784
|
+
}
|
|
2785
|
+
return rows.slice(first);
|
|
2786
|
+
}
|
|
2787
|
+
function snapshotToTerminalOutput(snapshot) {
|
|
2788
|
+
const rows = snapshot.replace(/\r?\n$/, "").split(/\r?\n/);
|
|
2789
|
+
return keepBottomRows(rows).join("\r\n");
|
|
2790
|
+
}
|
|
2503
2791
|
var PtyAttachmentManager = class {
|
|
2504
2792
|
handles = /* @__PURE__ */ new Map();
|
|
2505
2793
|
/**
|
|
2506
2794
|
* Attempts to attach (spawning a pty if one isn't already running for this
|
|
2507
2795
|
* session). Returns false, without throwing, if the pty could not be
|
|
2508
2796
|
* spawned — a spawn failure here must never take down the whole daemon
|
|
2509
|
-
* process, since every other session's pty would die with it.
|
|
2797
|
+
* process, since every other session's pty would die with it. `size` only
|
|
2798
|
+
* applies to a pty spawned here; an existing one keeps its current size.
|
|
2510
2799
|
*/
|
|
2511
|
-
async ensureAttached(sessionId, tmuxSessionName, onData) {
|
|
2800
|
+
async ensureAttached(sessionId, tmuxSessionName, onData, size = DEFAULT_PTY_SIZE) {
|
|
2512
2801
|
let handle = this.handles.get(sessionId);
|
|
2513
2802
|
if (!handle) {
|
|
2514
2803
|
let ptyProcess;
|
|
2515
2804
|
try {
|
|
2516
2805
|
ptyProcess = pty.spawn("tmux", ["attach-session", "-t", tmuxSessionName], {
|
|
2517
2806
|
name: "xterm-256color",
|
|
2518
|
-
cols:
|
|
2519
|
-
rows:
|
|
2807
|
+
cols: size.cols,
|
|
2808
|
+
rows: size.rows,
|
|
2520
2809
|
cwd: process.env.HOME,
|
|
2521
2810
|
env: filterSpawnEnv(process.env)
|
|
2522
2811
|
});
|
|
@@ -2528,20 +2817,17 @@ ${new TmuxNotFoundError().message}
|
|
|
2528
2817
|
}
|
|
2529
2818
|
return false;
|
|
2530
2819
|
}
|
|
2531
|
-
|
|
2532
|
-
this.handles.set(sessionId,
|
|
2820
|
+
const created = { process: ptyProcess, dataListeners: /* @__PURE__ */ new Set([onData]) };
|
|
2821
|
+
this.handles.set(sessionId, created);
|
|
2533
2822
|
ptyProcess.onData((data) => {
|
|
2534
|
-
for (const listener of
|
|
2823
|
+
for (const listener of created.dataListeners) listener(data);
|
|
2535
2824
|
});
|
|
2536
2825
|
ptyProcess.onExit(() => {
|
|
2537
|
-
this.handles.delete(sessionId);
|
|
2826
|
+
if (this.handles.get(sessionId) === created) this.handles.delete(sessionId);
|
|
2538
2827
|
});
|
|
2539
|
-
handle.refCount += 1;
|
|
2540
|
-
handle.dataListeners.add(onData);
|
|
2541
2828
|
return true;
|
|
2542
2829
|
}
|
|
2543
2830
|
await this.sendPaneSnapshot(tmuxSessionName, onData);
|
|
2544
|
-
handle.refCount += 1;
|
|
2545
2831
|
handle.dataListeners.add(onData);
|
|
2546
2832
|
return true;
|
|
2547
2833
|
}
|
|
@@ -2554,19 +2840,21 @@ ${new TmuxNotFoundError().message}
|
|
|
2554
2840
|
handle.dataListeners.add(nextOnData);
|
|
2555
2841
|
return true;
|
|
2556
2842
|
}
|
|
2843
|
+
/** The pane's current screen as terminal output; rejects if tmux cannot capture it. */
|
|
2844
|
+
async paneSnapshot(tmuxSessionName) {
|
|
2845
|
+
return snapshotToTerminalOutput(await capturePane(tmuxSessionName));
|
|
2846
|
+
}
|
|
2557
2847
|
// Best-effort: a capture failure just leaves this listener blank until the next real write.
|
|
2558
2848
|
async sendPaneSnapshot(tmuxSessionName, onData) {
|
|
2559
2849
|
try {
|
|
2560
|
-
onData(await
|
|
2850
|
+
onData(await this.paneSnapshot(tmuxSessionName));
|
|
2561
2851
|
} catch {
|
|
2562
2852
|
}
|
|
2563
2853
|
}
|
|
2564
2854
|
release(sessionId, onData) {
|
|
2565
2855
|
const handle = this.handles.get(sessionId);
|
|
2566
|
-
if (!handle) return;
|
|
2567
|
-
handle.dataListeners.
|
|
2568
|
-
handle.refCount -= 1;
|
|
2569
|
-
if (handle.refCount <= 0) {
|
|
2856
|
+
if (!handle?.dataListeners.delete(onData)) return;
|
|
2857
|
+
if (handle.dataListeners.size === 0) {
|
|
2570
2858
|
handle.process.kill();
|
|
2571
2859
|
this.handles.delete(sessionId);
|
|
2572
2860
|
}
|
|
@@ -2837,7 +3125,12 @@ var SessionManager = class {
|
|
|
2837
3125
|
if (error2 instanceof TmuxNotFoundError) console.error(`
|
|
2838
3126
|
${error2.message}
|
|
2839
3127
|
`);
|
|
2840
|
-
if (!(error2 instanceof TmuxNoServerRunningError))
|
|
3128
|
+
if (!(error2 instanceof TmuxNoServerRunningError)) {
|
|
3129
|
+
if (!(error2 instanceof TmuxNotFoundError)) {
|
|
3130
|
+
console.error("tmux list-sessions failed; skipping reconcile", error2);
|
|
3131
|
+
}
|
|
3132
|
+
return;
|
|
3133
|
+
}
|
|
2841
3134
|
}
|
|
2842
3135
|
await Promise.all(
|
|
2843
3136
|
Array.from(this.sessions.values()).map(async (session) => {
|
|
@@ -2911,6 +3204,8 @@ function createDaemonWebSocketServer(httpServer, config, daemonFingerprint, pend
|
|
|
2911
3204
|
const rejectionReasonByRequest = /* @__PURE__ */ new WeakMap();
|
|
2912
3205
|
const wss = new WebSocketServer({
|
|
2913
3206
|
server: httpServer,
|
|
3207
|
+
// ws defaults to 100 MiB per frame, parsed before any signature is checked.
|
|
3208
|
+
maxPayload: MAX_WIRE_MESSAGE_BYTES,
|
|
2914
3209
|
// Client always sends the dev-tunnel token as the sole entry (see
|
|
2915
3210
|
// isValidDevTunnelToken); echoing it back is required for a spec-
|
|
2916
3211
|
// compliant handshake once a Sec-WebSocket-Protocol is offered.
|
|
@@ -2934,6 +3229,9 @@ function createDaemonWebSocketServer(httpServer, config, daemonFingerprint, pend
|
|
|
2934
3229
|
broadcast({ type: "session.updated", session });
|
|
2935
3230
|
});
|
|
2936
3231
|
wss.on("connection", (socket, request) => {
|
|
3232
|
+
socket.on("error", (error2) => {
|
|
3233
|
+
console.warn("bondsmithd: browser socket error", error2.message);
|
|
3234
|
+
});
|
|
2937
3235
|
const rejectionReason = rejectionReasonByRequest.get(request);
|
|
2938
3236
|
if (rejectionReason) {
|
|
2939
3237
|
rejectionReasonByRequest.delete(request);
|
|
@@ -2948,7 +3246,8 @@ function createDaemonWebSocketServer(httpServer, config, daemonFingerprint, pend
|
|
|
2948
3246
|
if (socket.readyState === socket.OPEN) socket.send(JSON.stringify(message));
|
|
2949
3247
|
},
|
|
2950
3248
|
(controllerId) => registerControllerConnection(controllerId, socket),
|
|
2951
|
-
ptyAttachments
|
|
3249
|
+
ptyAttachments,
|
|
3250
|
+
() => socket.bufferedAmount
|
|
2952
3251
|
);
|
|
2953
3252
|
cleanupBySocket.set(socket, releaseAllSubscriptions);
|
|
2954
3253
|
connectionAuthBySocket.set(socket, connectionAuth);
|
|
@@ -3012,6 +3311,10 @@ function createDaemonWebSocketServer(httpServer, config, daemonFingerprint, pend
|
|
|
3012
3311
|
}
|
|
3013
3312
|
|
|
3014
3313
|
// src/index.ts
|
|
3314
|
+
if (isVersionRequest(process.argv.slice(2))) {
|
|
3315
|
+
console.log(versionLine());
|
|
3316
|
+
process.exit(0);
|
|
3317
|
+
}
|
|
3015
3318
|
function loadRepoRootEnv() {
|
|
3016
3319
|
const repoRoot = join6(dirname4(fileURLToPath(import.meta.url)), "..", "..", "..");
|
|
3017
3320
|
if (!existsSync(join6(repoRoot, "pnpm-workspace.yaml"))) return;
|
|
@@ -3021,6 +3324,7 @@ function loadRepoRootEnv() {
|
|
|
3021
3324
|
}
|
|
3022
3325
|
loadRepoRootEnv();
|
|
3023
3326
|
async function start() {
|
|
3327
|
+
console.log(versionLine());
|
|
3024
3328
|
let config = getDaemonConfig();
|
|
3025
3329
|
if (!config.devTunnelToken) {
|
|
3026
3330
|
const generatedToken = await loadOrCreateDevTunnelToken();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bondsmith/bondsmithd",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.5",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Local daemon for Bondsmith agent sessions — runs on the user's own machine, pairs with a Bondsmith account, and exposes tmux-backed terminal sessions to the Bondsmith web app over a signed WebSocket protocol.",
|
|
6
6
|
"homepage": "https://bondsmith.dev",
|