@bondsmith/bondsmithd 0.1.3 → 0.1.4
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 +443 -138
- 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.4" : "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 };
|
|
@@ -1336,8 +1477,17 @@ async function dispatch(conn, message, {
|
|
|
1336
1477
|
}
|
|
1337
1478
|
await conn.withAttachLock(message.sessionId, async () => {
|
|
1338
1479
|
const previousListener = conn.getOutputListener(message.sessionId);
|
|
1339
|
-
const
|
|
1340
|
-
conn.send({ type: "terminal.output", sessionId: message.sessionId, data })
|
|
1480
|
+
const output = createOutputPacer({
|
|
1481
|
+
send: (data) => conn.send({ type: "terminal.output", sessionId: message.sessionId, data }),
|
|
1482
|
+
bufferedBytes: conn.bufferedBytes,
|
|
1483
|
+
snapshot: () => ptyAttachments.paneSnapshot(session.tmuxSessionName)
|
|
1484
|
+
});
|
|
1485
|
+
const onData = output.write;
|
|
1486
|
+
const abandonedBecauseClosed = () => {
|
|
1487
|
+
if (!conn.isClosed()) return false;
|
|
1488
|
+
ptyAttachments.release(message.sessionId, onData);
|
|
1489
|
+
output.dispose();
|
|
1490
|
+
return true;
|
|
1341
1491
|
};
|
|
1342
1492
|
if (previousListener && await ptyAttachments.swapListener(
|
|
1343
1493
|
message.sessionId,
|
|
@@ -1345,29 +1495,41 @@ async function dispatch(conn, message, {
|
|
|
1345
1495
|
previousListener,
|
|
1346
1496
|
onData
|
|
1347
1497
|
)) {
|
|
1498
|
+
if (abandonedBecauseClosed()) return;
|
|
1348
1499
|
conn.subscriptions.add(message.sessionId);
|
|
1349
|
-
conn.registerOutputListener(message.sessionId, onData);
|
|
1500
|
+
conn.registerOutputListener(message.sessionId, onData, output.dispose);
|
|
1350
1501
|
conn.send({ type: "session.attached", sessionId: message.sessionId, requestId });
|
|
1351
1502
|
return;
|
|
1352
1503
|
}
|
|
1353
1504
|
if (previousListener) {
|
|
1354
1505
|
ptyAttachments.release(message.sessionId, previousListener);
|
|
1506
|
+
conn.unregisterOutputListener(message.sessionId);
|
|
1355
1507
|
}
|
|
1356
1508
|
const attached = await ptyAttachments.ensureAttached(
|
|
1357
1509
|
message.sessionId,
|
|
1358
1510
|
session.tmuxSessionName,
|
|
1359
|
-
onData
|
|
1511
|
+
onData,
|
|
1512
|
+
message.size
|
|
1360
1513
|
);
|
|
1361
1514
|
if (!attached) {
|
|
1515
|
+
output.dispose();
|
|
1362
1516
|
conn.send(error("failed to attach to session", requestId));
|
|
1363
1517
|
return;
|
|
1364
1518
|
}
|
|
1519
|
+
if (abandonedBecauseClosed()) return;
|
|
1365
1520
|
conn.subscriptions.add(message.sessionId);
|
|
1366
|
-
conn.registerOutputListener(message.sessionId, onData);
|
|
1521
|
+
conn.registerOutputListener(message.sessionId, onData, output.dispose);
|
|
1367
1522
|
conn.send({ type: "session.attached", sessionId: message.sessionId, requestId });
|
|
1368
1523
|
});
|
|
1369
1524
|
return;
|
|
1370
1525
|
}
|
|
1526
|
+
case "session.detach": {
|
|
1527
|
+
if (!isValidSessionId(message.sessionId)) return;
|
|
1528
|
+
await conn.withAttachLock(message.sessionId, async () => {
|
|
1529
|
+
releaseSession(conn, ptyAttachments, message.sessionId);
|
|
1530
|
+
});
|
|
1531
|
+
return;
|
|
1532
|
+
}
|
|
1371
1533
|
case "session.input": {
|
|
1372
1534
|
if (!conn.subscriptions.has(message.sessionId)) {
|
|
1373
1535
|
conn.send(error("not attached to session"));
|
|
@@ -1771,7 +1933,9 @@ function startRelayClient(deps) {
|
|
|
1771
1933
|
},
|
|
1772
1934
|
() => {
|
|
1773
1935
|
},
|
|
1774
|
-
deps.ptyAttachments
|
|
1936
|
+
deps.ptyAttachments,
|
|
1937
|
+
// One socket carries every browser session, so a backed-up relay link slows all of them together.
|
|
1938
|
+
() => ws.bufferedAmount
|
|
1775
1939
|
);
|
|
1776
1940
|
}
|
|
1777
1941
|
function handleSessionOpened(ws, relaySessionId) {
|
|
@@ -1999,12 +2163,27 @@ async function persistToken(tokenPath, token) {
|
|
|
1999
2163
|
|
|
2000
2164
|
// src/security/pending-authorizations.ts
|
|
2001
2165
|
import { createHash, randomUUID } from "crypto";
|
|
2166
|
+
|
|
2167
|
+
// src/security/controller-label.ts
|
|
2168
|
+
var MAX_LABEL_CHARACTERS = 60;
|
|
2169
|
+
var MAX_STACKED_MARKS = 3;
|
|
2170
|
+
var FALLBACK_LABEL = "Unnamed browser";
|
|
2171
|
+
var EXCESS_MARKS = new RegExp(`(\\p{M}{${MAX_STACKED_MARKS}})\\p{M}+`, "gu");
|
|
2172
|
+
function sanitizeControllerLabel(raw) {
|
|
2173
|
+
const printable = raw.replace(/\p{C}/gu, " ").replace(EXCESS_MARKS, "$1").replace(/\s+/g, " ").trim();
|
|
2174
|
+
const clipped = Array.from(printable).slice(0, MAX_LABEL_CHARACTERS).join("").trim();
|
|
2175
|
+
return clipped === "" ? FALLBACK_LABEL : clipped;
|
|
2176
|
+
}
|
|
2177
|
+
|
|
2178
|
+
// src/security/pending-authorizations.ts
|
|
2002
2179
|
function controllerIdFromPublicKeySync(publicKeyPem) {
|
|
2003
2180
|
return createHash("sha256").update(publicKeyPem).digest("hex").slice(0, 32);
|
|
2004
2181
|
}
|
|
2005
2182
|
var REQUEST_EXPIRY_MS = 5 * 6e4;
|
|
2006
2183
|
var MAX_REQUESTS_PER_WINDOW = 5;
|
|
2007
2184
|
var RATE_LIMIT_WINDOW_MS = 6e4;
|
|
2185
|
+
var MAX_PENDING_REQUESTS = 5;
|
|
2186
|
+
var MAX_NEW_REQUESTS_PER_WINDOW = 10;
|
|
2008
2187
|
var PendingAuthorizationStore = class {
|
|
2009
2188
|
// Invariant: at most one PendingEntry per controllerId at any time. A
|
|
2010
2189
|
// conflicting requestEnrollment call for a controllerId that already has
|
|
@@ -2015,6 +2194,7 @@ var PendingAuthorizationStore = class {
|
|
|
2015
2194
|
// resolve a newer, unrelated one.
|
|
2016
2195
|
pending = /* @__PURE__ */ new Map();
|
|
2017
2196
|
requestTimestamps = /* @__PURE__ */ new Map();
|
|
2197
|
+
allRequestTimestamps = [];
|
|
2018
2198
|
isInteractive;
|
|
2019
2199
|
trustedControllersPath;
|
|
2020
2200
|
constructor(options = {}) {
|
|
@@ -2060,7 +2240,7 @@ var PendingAuthorizationStore = class {
|
|
|
2060
2240
|
result: existing.resultPromise
|
|
2061
2241
|
};
|
|
2062
2242
|
}
|
|
2063
|
-
if (this.isRateLimited(controllerId)) {
|
|
2243
|
+
if (this.isRateLimited(controllerId) || this.isOverGlobalLimits()) {
|
|
2064
2244
|
return { ok: false, reason: "rate_limited" };
|
|
2065
2245
|
}
|
|
2066
2246
|
this.recordRequestTimestamp(controllerId);
|
|
@@ -2068,7 +2248,7 @@ var PendingAuthorizationStore = class {
|
|
|
2068
2248
|
const authorization = {
|
|
2069
2249
|
controllerId,
|
|
2070
2250
|
publicKeyPem: params.publicKeyPem,
|
|
2071
|
-
label: params.label,
|
|
2251
|
+
label: sanitizeControllerLabel(params.label),
|
|
2072
2252
|
fingerprint: "",
|
|
2073
2253
|
requestedAt: now,
|
|
2074
2254
|
expiresAt: now + REQUEST_EXPIRY_MS,
|
|
@@ -2156,19 +2336,33 @@ var PendingAuthorizationStore = class {
|
|
|
2156
2336
|
entry.settle("cancelled");
|
|
2157
2337
|
}
|
|
2158
2338
|
}
|
|
2339
|
+
/** True while this exact request is still waiting for an answer, so a queued prompt can skip one that expired or was cancelled. */
|
|
2340
|
+
isPending(controllerId, requestToken) {
|
|
2341
|
+
const entry = this.pending.get(controllerId);
|
|
2342
|
+
return entry !== void 0 && !entry.settled && entry.authorization.requestToken === requestToken && entry.authorization.expiresAt > Date.now();
|
|
2343
|
+
}
|
|
2159
2344
|
isRateLimited(controllerId) {
|
|
2160
2345
|
const timestamps = this.requestTimestamps.get(controllerId) ?? [];
|
|
2161
2346
|
const windowStart = Date.now() - RATE_LIMIT_WINDOW_MS;
|
|
2162
2347
|
const recent = timestamps.filter((t) => t > windowStart);
|
|
2163
2348
|
return recent.length >= MAX_REQUESTS_PER_WINDOW;
|
|
2164
2349
|
}
|
|
2165
|
-
|
|
2350
|
+
isOverGlobalLimits() {
|
|
2166
2351
|
const windowStart = Date.now() - RATE_LIMIT_WINDOW_MS;
|
|
2167
|
-
const
|
|
2168
|
-
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
|
|
2352
|
+
const recent = this.allRequestTimestamps.filter((t) => t > windowStart);
|
|
2353
|
+
return recent.length >= MAX_NEW_REQUESTS_PER_WINDOW || this.pending.size >= MAX_PENDING_REQUESTS;
|
|
2354
|
+
}
|
|
2355
|
+
recordRequestTimestamp(controllerId) {
|
|
2356
|
+
const now = Date.now();
|
|
2357
|
+
const windowStart = now - RATE_LIMIT_WINDOW_MS;
|
|
2358
|
+
this.allRequestTimestamps = this.allRequestTimestamps.filter((t) => t > windowStart);
|
|
2359
|
+
this.allRequestTimestamps.push(now);
|
|
2360
|
+
for (const [key, timestamps] of this.requestTimestamps) {
|
|
2361
|
+
if (!timestamps.some((t) => t > windowStart)) this.requestTimestamps.delete(key);
|
|
2362
|
+
}
|
|
2363
|
+
const recent = (this.requestTimestamps.get(controllerId) ?? []).filter((t) => t > windowStart);
|
|
2364
|
+
recent.push(now);
|
|
2365
|
+
this.requestTimestamps.set(controllerId, recent);
|
|
2172
2366
|
}
|
|
2173
2367
|
};
|
|
2174
2368
|
|
|
@@ -2280,42 +2474,101 @@ function startRevocationSync(deps) {
|
|
|
2280
2474
|
// src/security/terminal-approval.ts
|
|
2281
2475
|
import { createInterface } from "readline";
|
|
2282
2476
|
function presentPendingAuthorizationOnTerminal(store) {
|
|
2283
|
-
|
|
2477
|
+
const waiting = [];
|
|
2478
|
+
let prompting = false;
|
|
2479
|
+
let inputEnded = false;
|
|
2480
|
+
const record = (pending, approved) => {
|
|
2481
|
+
store.resolve(pending.controllerId, pending.requestToken, approved).catch((error2) => {
|
|
2482
|
+
console.error("Failed to record controller authorization decision", error2);
|
|
2483
|
+
});
|
|
2484
|
+
};
|
|
2485
|
+
const askNext = () => {
|
|
2486
|
+
if (prompting) return;
|
|
2487
|
+
const pending = waiting.shift();
|
|
2488
|
+
if (!pending) return;
|
|
2489
|
+
if (!store.isPending(pending.controllerId, pending.requestToken)) {
|
|
2490
|
+
askNext();
|
|
2491
|
+
return;
|
|
2492
|
+
}
|
|
2493
|
+
if (inputEnded) {
|
|
2494
|
+
record(pending, false);
|
|
2495
|
+
askNext();
|
|
2496
|
+
return;
|
|
2497
|
+
}
|
|
2498
|
+
prompting = true;
|
|
2284
2499
|
console.log("");
|
|
2285
2500
|
console.log(" Bondsmith controller authorization request");
|
|
2286
|
-
console.log(` Browser: ${pending.label}`);
|
|
2501
|
+
console.log(` Browser: "${pending.label}"`);
|
|
2287
2502
|
console.log(` Fingerprint: ${pending.fingerprint}`);
|
|
2503
|
+
if (waiting.length > 0) console.log(` (${waiting.length} more waiting after this one)`);
|
|
2288
2504
|
console.log("");
|
|
2289
2505
|
console.log(" Allow this browser to control local sessions? [y/N]");
|
|
2290
2506
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
2291
|
-
|
|
2507
|
+
let answered = false;
|
|
2508
|
+
let expired = false;
|
|
2509
|
+
const expiryTimer = setTimeout(
|
|
2510
|
+
() => {
|
|
2511
|
+
expired = true;
|
|
2512
|
+
rl.close();
|
|
2513
|
+
},
|
|
2514
|
+
Math.max(0, pending.expiresAt - Date.now())
|
|
2515
|
+
);
|
|
2516
|
+
const finish = (approved) => {
|
|
2517
|
+
if (answered) return;
|
|
2518
|
+
answered = true;
|
|
2519
|
+
clearTimeout(expiryTimer);
|
|
2292
2520
|
rl.close();
|
|
2293
|
-
|
|
2294
|
-
|
|
2295
|
-
|
|
2296
|
-
});
|
|
2297
|
-
console.log(approved ? " Approved." : " Denied.");
|
|
2521
|
+
record(pending, approved);
|
|
2522
|
+
if (expired) console.log(" Request expired.");
|
|
2523
|
+
else console.log(approved ? " Approved." : " Denied.");
|
|
2298
2524
|
console.log("");
|
|
2525
|
+
prompting = false;
|
|
2526
|
+
askNext();
|
|
2527
|
+
};
|
|
2528
|
+
rl.question(" > ", (answer) => finish(answer.trim().toLowerCase() === "y"));
|
|
2529
|
+
rl.on("SIGINT", () => finish(false));
|
|
2530
|
+
rl.on("close", () => {
|
|
2531
|
+
if (!answered && !expired) inputEnded = true;
|
|
2532
|
+
finish(false);
|
|
2299
2533
|
});
|
|
2300
2534
|
};
|
|
2535
|
+
return (pending) => {
|
|
2536
|
+
waiting.push(pending);
|
|
2537
|
+
askNext();
|
|
2538
|
+
};
|
|
2301
2539
|
}
|
|
2302
2540
|
|
|
2303
|
-
// src/sessions/git/
|
|
2304
|
-
|
|
2305
|
-
|
|
2306
|
-
|
|
2307
|
-
|
|
2308
|
-
|
|
2309
|
-
|
|
2541
|
+
// src/sessions/git/base.ts
|
|
2542
|
+
var EMPTY_TREE_SHA = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
|
|
2543
|
+
var DEFAULT_BRANCH_CANDIDATES = ["origin/main", "origin/master", "main", "master"];
|
|
2544
|
+
async function tryGit(args, cwd) {
|
|
2545
|
+
try {
|
|
2546
|
+
return (await runGit(args, cwd)).trim() || null;
|
|
2547
|
+
} catch {
|
|
2548
|
+
return null;
|
|
2310
2549
|
}
|
|
2311
|
-
|
|
2312
|
-
|
|
2313
|
-
|
|
2314
|
-
|
|
2315
|
-
|
|
2316
|
-
|
|
2550
|
+
}
|
|
2551
|
+
async function findDefaultBranchRef(cwd) {
|
|
2552
|
+
const remoteHead = await tryGit(
|
|
2553
|
+
["symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD"],
|
|
2554
|
+
cwd
|
|
2555
|
+
);
|
|
2556
|
+
if (remoteHead) return remoteHead;
|
|
2557
|
+
for (const candidate of DEFAULT_BRANCH_CANDIDATES) {
|
|
2558
|
+
if (await tryGit(["rev-parse", "--verify", "--quiet", `${candidate}^{commit}`], cwd)) {
|
|
2559
|
+
return candidate;
|
|
2560
|
+
}
|
|
2317
2561
|
}
|
|
2318
|
-
return
|
|
2562
|
+
return null;
|
|
2563
|
+
}
|
|
2564
|
+
async function resolveComparisonBase(cwd) {
|
|
2565
|
+
if (!await tryGit(["rev-parse", "--verify", "--quiet", "HEAD^{commit}"], cwd)) {
|
|
2566
|
+
return { sha: EMPTY_TREE_SHA, ref: null };
|
|
2567
|
+
}
|
|
2568
|
+
const defaultRef = await findDefaultBranchRef(cwd);
|
|
2569
|
+
if (!defaultRef) return { sha: "HEAD", ref: null };
|
|
2570
|
+
const mergeBase = await tryGit(["merge-base", "HEAD", defaultRef], cwd);
|
|
2571
|
+
return mergeBase ? { sha: mergeBase, ref: defaultRef } : { sha: "HEAD", ref: null };
|
|
2319
2572
|
}
|
|
2320
2573
|
|
|
2321
2574
|
// src/sessions/git/status.ts
|
|
@@ -2325,7 +2578,11 @@ async function getCurrentBranch(cwd) {
|
|
|
2325
2578
|
return out === "" || out === "HEAD" ? null : out;
|
|
2326
2579
|
} catch (err) {
|
|
2327
2580
|
if (err instanceof GitNotARepoError) return null;
|
|
2328
|
-
|
|
2581
|
+
try {
|
|
2582
|
+
return (await runGit(["symbolic-ref", "--quiet", "--short", "HEAD"], cwd)).trim() || null;
|
|
2583
|
+
} catch {
|
|
2584
|
+
throw err;
|
|
2585
|
+
}
|
|
2329
2586
|
}
|
|
2330
2587
|
}
|
|
2331
2588
|
async function getRepositoryFullName(cwd) {
|
|
@@ -2375,88 +2632,73 @@ function parseNumstat(output) {
|
|
|
2375
2632
|
}
|
|
2376
2633
|
return map;
|
|
2377
2634
|
}
|
|
2378
|
-
var
|
|
2635
|
+
var STATUS_BY_LETTER = {
|
|
2379
2636
|
A: "added",
|
|
2380
2637
|
M: "modified",
|
|
2381
2638
|
D: "deleted",
|
|
2382
2639
|
R: "renamed",
|
|
2383
2640
|
C: "copied",
|
|
2641
|
+
T: "modified",
|
|
2642
|
+
// type change, such as a file becoming a symlink
|
|
2384
2643
|
U: "modified"
|
|
2385
|
-
// unmerged
|
|
2644
|
+
// unmerged: no conflict-resolution UI yet, so it reads as modified
|
|
2386
2645
|
};
|
|
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);
|
|
2646
|
+
function isStatusLetter(letter) {
|
|
2647
|
+
return letter in STATUS_BY_LETTER;
|
|
2648
|
+
}
|
|
2649
|
+
function parseNameStatus(output) {
|
|
2650
|
+
const fields = output.split("\0").filter((f) => f.length > 0);
|
|
2651
|
+
const changed = [];
|
|
2405
2652
|
let i = 0;
|
|
2406
2653
|
while (i < fields.length) {
|
|
2407
|
-
const
|
|
2408
|
-
const
|
|
2409
|
-
|
|
2410
|
-
|
|
2411
|
-
|
|
2412
|
-
|
|
2413
|
-
|
|
2414
|
-
|
|
2415
|
-
|
|
2416
|
-
|
|
2417
|
-
|
|
2654
|
+
const letter = fields[i++][0] ?? "";
|
|
2655
|
+
const status = isStatusLetter(letter) ? STATUS_BY_LETTER[letter] : "modified";
|
|
2656
|
+
if (status === "renamed" || status === "copied") {
|
|
2657
|
+
const previousPath = fields[i++];
|
|
2658
|
+
const path = fields[i++];
|
|
2659
|
+
if (previousPath === void 0 || path === void 0) break;
|
|
2660
|
+
changed.push({ path, previousPath, status });
|
|
2661
|
+
} else {
|
|
2662
|
+
const path = fields[i++];
|
|
2663
|
+
if (path === void 0) break;
|
|
2664
|
+
changed.push({ path, status });
|
|
2418
2665
|
}
|
|
2419
|
-
pushEntriesForPath(entries, path, x, y, void 0, unstagedNumstat, stagedNumstat);
|
|
2420
2666
|
}
|
|
2421
|
-
return
|
|
2667
|
+
return changed;
|
|
2422
2668
|
}
|
|
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
|
-
});
|
|
2669
|
+
async function getWorkspaceStatus(cwd) {
|
|
2670
|
+
const branch = await getCurrentBranch(cwd);
|
|
2671
|
+
if (branch === null && !await isGitRepo(cwd)) {
|
|
2672
|
+
return { isGitRepo: false, branch: null, files: [] };
|
|
2447
2673
|
}
|
|
2448
|
-
|
|
2449
|
-
|
|
2450
|
-
|
|
2451
|
-
|
|
2452
|
-
|
|
2453
|
-
|
|
2674
|
+
const base = await resolveComparisonBase(cwd);
|
|
2675
|
+
const [nameStatusRaw, numstatRaw, untrackedRaw] = await Promise.all([
|
|
2676
|
+
runGitDiff(["--name-status", "-z", "-M", base.sha], cwd),
|
|
2677
|
+
runGitDiff(["--numstat", "-z", "-M", base.sha], cwd),
|
|
2678
|
+
runGit(["ls-files", "--others", "--exclude-standard", "-z"], cwd)
|
|
2679
|
+
]);
|
|
2680
|
+
const numstat = parseNumstat(numstatRaw);
|
|
2681
|
+
const tracked = parseNameStatus(nameStatusRaw).map((changed) => {
|
|
2682
|
+
const counts = numstat.get(changed.path);
|
|
2683
|
+
return {
|
|
2684
|
+
path: changed.path,
|
|
2685
|
+
previousPath: changed.previousPath,
|
|
2686
|
+
status: changed.status,
|
|
2454
2687
|
staged: false,
|
|
2455
|
-
additions:
|
|
2456
|
-
deletions:
|
|
2457
|
-
binary:
|
|
2458
|
-
}
|
|
2459
|
-
}
|
|
2688
|
+
additions: counts?.additions ?? null,
|
|
2689
|
+
deletions: counts?.deletions ?? null,
|
|
2690
|
+
binary: counts?.binary ?? false
|
|
2691
|
+
};
|
|
2692
|
+
});
|
|
2693
|
+
const untracked = untrackedRaw.split("\0").filter((path) => path.length > 0).map((path) => ({
|
|
2694
|
+
path,
|
|
2695
|
+
status: "untracked",
|
|
2696
|
+
staged: false,
|
|
2697
|
+
additions: null,
|
|
2698
|
+
deletions: null,
|
|
2699
|
+
binary: false
|
|
2700
|
+
}));
|
|
2701
|
+
return { isGitRepo: true, branch, baseRef: base.ref, files: [...tracked, ...untracked] };
|
|
2460
2702
|
}
|
|
2461
2703
|
async function enrichSessionWithGitMetadata(session) {
|
|
2462
2704
|
const [branch, repositoryFullName, status, lastActivityAt] = await Promise.all([
|
|
@@ -2475,6 +2717,33 @@ async function enrichSessionWithGitMetadata(session) {
|
|
|
2475
2717
|
};
|
|
2476
2718
|
}
|
|
2477
2719
|
|
|
2720
|
+
// src/sessions/git/diff.ts
|
|
2721
|
+
async function withRenameSource(cwd, baseSha, path) {
|
|
2722
|
+
const nameStatus = await runGitDiff(["--name-status", "-z", "-M", baseSha], cwd);
|
|
2723
|
+
const previousPath = parseNameStatus(nameStatus).find((c) => c.path === path)?.previousPath;
|
|
2724
|
+
return previousPath === void 0 ? [path] : [previousPath, path];
|
|
2725
|
+
}
|
|
2726
|
+
async function isUntracked(cwd, path) {
|
|
2727
|
+
const out = await runGit(["ls-files", "--others", "--exclude-standard", "--", path], cwd);
|
|
2728
|
+
return out.trim() !== "";
|
|
2729
|
+
}
|
|
2730
|
+
async function getWorkspaceDiff(cwd, path, staged) {
|
|
2731
|
+
const base = await resolveComparisonBase(cwd);
|
|
2732
|
+
const pathspec = await withRenameSource(cwd, base.sha, path);
|
|
2733
|
+
const numstat = await runGitDiff(["--numstat", "-M", base.sha, "--", ...pathspec], cwd);
|
|
2734
|
+
const binary = /^(-\t-\t)/.test(numstat.trim());
|
|
2735
|
+
if (binary) {
|
|
2736
|
+
return { path, staged, patch: null, binary: true };
|
|
2737
|
+
}
|
|
2738
|
+
let patch = await runGitDiff(["-M", base.sha, "--", ...pathspec], cwd);
|
|
2739
|
+
if (patch.trim() === "" && await isUntracked(cwd, path)) {
|
|
2740
|
+
patch = await runGitDiffAllowingExitCodes(["--no-index", "--", "/dev/null", path], cwd, [
|
|
2741
|
+
1
|
|
2742
|
+
]).catch(() => "");
|
|
2743
|
+
}
|
|
2744
|
+
return { path, staged, patch: patch || null, binary: false };
|
|
2745
|
+
}
|
|
2746
|
+
|
|
2478
2747
|
// src/ws/server.ts
|
|
2479
2748
|
import { hostname } from "os";
|
|
2480
2749
|
import { WebSocketServer } from "ws";
|
|
@@ -2500,23 +2769,40 @@ function isAllowedOrigin(origin, allowlist) {
|
|
|
2500
2769
|
|
|
2501
2770
|
// src/sessions/pty-attachment.ts
|
|
2502
2771
|
import * as pty from "node-pty";
|
|
2772
|
+
var DEFAULT_PTY_SIZE = { cols: 80, rows: 24 };
|
|
2773
|
+
var MAX_SNAPSHOT_JSON_BYTES = 128 * 1024;
|
|
2774
|
+
function keepBottomRows(rows) {
|
|
2775
|
+
let budget = MAX_SNAPSHOT_JSON_BYTES;
|
|
2776
|
+
let first = rows.length;
|
|
2777
|
+
while (first > 0) {
|
|
2778
|
+
budget -= JSON.stringify(rows[first - 1]).length;
|
|
2779
|
+
if (budget < 0) break;
|
|
2780
|
+
first -= 1;
|
|
2781
|
+
}
|
|
2782
|
+
return rows.slice(first);
|
|
2783
|
+
}
|
|
2784
|
+
function snapshotToTerminalOutput(snapshot) {
|
|
2785
|
+
const rows = snapshot.replace(/\r?\n$/, "").split(/\r?\n/);
|
|
2786
|
+
return keepBottomRows(rows).join("\r\n");
|
|
2787
|
+
}
|
|
2503
2788
|
var PtyAttachmentManager = class {
|
|
2504
2789
|
handles = /* @__PURE__ */ new Map();
|
|
2505
2790
|
/**
|
|
2506
2791
|
* Attempts to attach (spawning a pty if one isn't already running for this
|
|
2507
2792
|
* session). Returns false, without throwing, if the pty could not be
|
|
2508
2793
|
* spawned — a spawn failure here must never take down the whole daemon
|
|
2509
|
-
* process, since every other session's pty would die with it.
|
|
2794
|
+
* process, since every other session's pty would die with it. `size` only
|
|
2795
|
+
* applies to a pty spawned here; an existing one keeps its current size.
|
|
2510
2796
|
*/
|
|
2511
|
-
async ensureAttached(sessionId, tmuxSessionName, onData) {
|
|
2797
|
+
async ensureAttached(sessionId, tmuxSessionName, onData, size = DEFAULT_PTY_SIZE) {
|
|
2512
2798
|
let handle = this.handles.get(sessionId);
|
|
2513
2799
|
if (!handle) {
|
|
2514
2800
|
let ptyProcess;
|
|
2515
2801
|
try {
|
|
2516
2802
|
ptyProcess = pty.spawn("tmux", ["attach-session", "-t", tmuxSessionName], {
|
|
2517
2803
|
name: "xterm-256color",
|
|
2518
|
-
cols:
|
|
2519
|
-
rows:
|
|
2804
|
+
cols: size.cols,
|
|
2805
|
+
rows: size.rows,
|
|
2520
2806
|
cwd: process.env.HOME,
|
|
2521
2807
|
env: filterSpawnEnv(process.env)
|
|
2522
2808
|
});
|
|
@@ -2528,16 +2814,16 @@ ${new TmuxNotFoundError().message}
|
|
|
2528
2814
|
}
|
|
2529
2815
|
return false;
|
|
2530
2816
|
}
|
|
2531
|
-
|
|
2532
|
-
this.handles.set(sessionId,
|
|
2817
|
+
const created = { process: ptyProcess, refCount: 0, dataListeners: /* @__PURE__ */ new Set() };
|
|
2818
|
+
this.handles.set(sessionId, created);
|
|
2533
2819
|
ptyProcess.onData((data) => {
|
|
2534
|
-
for (const listener of
|
|
2820
|
+
for (const listener of created.dataListeners) listener(data);
|
|
2535
2821
|
});
|
|
2536
2822
|
ptyProcess.onExit(() => {
|
|
2537
|
-
this.handles.delete(sessionId);
|
|
2823
|
+
if (this.handles.get(sessionId) === created) this.handles.delete(sessionId);
|
|
2538
2824
|
});
|
|
2539
|
-
|
|
2540
|
-
|
|
2825
|
+
created.refCount += 1;
|
|
2826
|
+
created.dataListeners.add(onData);
|
|
2541
2827
|
return true;
|
|
2542
2828
|
}
|
|
2543
2829
|
await this.sendPaneSnapshot(tmuxSessionName, onData);
|
|
@@ -2554,17 +2840,20 @@ ${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.delete(onData);
|
|
2856
|
+
if (!handle?.dataListeners.delete(onData)) return;
|
|
2568
2857
|
handle.refCount -= 1;
|
|
2569
2858
|
if (handle.refCount <= 0) {
|
|
2570
2859
|
handle.process.kill();
|
|
@@ -2837,7 +3126,12 @@ var SessionManager = class {
|
|
|
2837
3126
|
if (error2 instanceof TmuxNotFoundError) console.error(`
|
|
2838
3127
|
${error2.message}
|
|
2839
3128
|
`);
|
|
2840
|
-
if (!(error2 instanceof TmuxNoServerRunningError))
|
|
3129
|
+
if (!(error2 instanceof TmuxNoServerRunningError)) {
|
|
3130
|
+
if (!(error2 instanceof TmuxNotFoundError)) {
|
|
3131
|
+
console.error("tmux list-sessions failed; skipping reconcile", error2);
|
|
3132
|
+
}
|
|
3133
|
+
return;
|
|
3134
|
+
}
|
|
2841
3135
|
}
|
|
2842
3136
|
await Promise.all(
|
|
2843
3137
|
Array.from(this.sessions.values()).map(async (session) => {
|
|
@@ -2911,6 +3205,8 @@ function createDaemonWebSocketServer(httpServer, config, daemonFingerprint, pend
|
|
|
2911
3205
|
const rejectionReasonByRequest = /* @__PURE__ */ new WeakMap();
|
|
2912
3206
|
const wss = new WebSocketServer({
|
|
2913
3207
|
server: httpServer,
|
|
3208
|
+
// ws defaults to 100 MiB per frame, parsed before any signature is checked.
|
|
3209
|
+
maxPayload: MAX_WIRE_MESSAGE_BYTES,
|
|
2914
3210
|
// Client always sends the dev-tunnel token as the sole entry (see
|
|
2915
3211
|
// isValidDevTunnelToken); echoing it back is required for a spec-
|
|
2916
3212
|
// compliant handshake once a Sec-WebSocket-Protocol is offered.
|
|
@@ -2934,6 +3230,9 @@ function createDaemonWebSocketServer(httpServer, config, daemonFingerprint, pend
|
|
|
2934
3230
|
broadcast({ type: "session.updated", session });
|
|
2935
3231
|
});
|
|
2936
3232
|
wss.on("connection", (socket, request) => {
|
|
3233
|
+
socket.on("error", (error2) => {
|
|
3234
|
+
console.warn("bondsmithd: browser socket error", error2.message);
|
|
3235
|
+
});
|
|
2937
3236
|
const rejectionReason = rejectionReasonByRequest.get(request);
|
|
2938
3237
|
if (rejectionReason) {
|
|
2939
3238
|
rejectionReasonByRequest.delete(request);
|
|
@@ -2948,7 +3247,8 @@ function createDaemonWebSocketServer(httpServer, config, daemonFingerprint, pend
|
|
|
2948
3247
|
if (socket.readyState === socket.OPEN) socket.send(JSON.stringify(message));
|
|
2949
3248
|
},
|
|
2950
3249
|
(controllerId) => registerControllerConnection(controllerId, socket),
|
|
2951
|
-
ptyAttachments
|
|
3250
|
+
ptyAttachments,
|
|
3251
|
+
() => socket.bufferedAmount
|
|
2952
3252
|
);
|
|
2953
3253
|
cleanupBySocket.set(socket, releaseAllSubscriptions);
|
|
2954
3254
|
connectionAuthBySocket.set(socket, connectionAuth);
|
|
@@ -3012,6 +3312,10 @@ function createDaemonWebSocketServer(httpServer, config, daemonFingerprint, pend
|
|
|
3012
3312
|
}
|
|
3013
3313
|
|
|
3014
3314
|
// src/index.ts
|
|
3315
|
+
if (isVersionRequest(process.argv.slice(2))) {
|
|
3316
|
+
console.log(versionLine());
|
|
3317
|
+
process.exit(0);
|
|
3318
|
+
}
|
|
3015
3319
|
function loadRepoRootEnv() {
|
|
3016
3320
|
const repoRoot = join6(dirname4(fileURLToPath(import.meta.url)), "..", "..", "..");
|
|
3017
3321
|
if (!existsSync(join6(repoRoot, "pnpm-workspace.yaml"))) return;
|
|
@@ -3021,6 +3325,7 @@ function loadRepoRootEnv() {
|
|
|
3021
3325
|
}
|
|
3022
3326
|
loadRepoRootEnv();
|
|
3023
3327
|
async function start() {
|
|
3328
|
+
console.log(versionLine());
|
|
3024
3329
|
let config = getDaemonConfig();
|
|
3025
3330
|
if (!config.devTunnelToken) {
|
|
3026
3331
|
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.4",
|
|
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",
|