@bondsmith/bondsmithd 0.1.9 → 0.1.11
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 +8 -1
- package/dist/index.js +949 -676
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -16,7 +16,7 @@ var REPLAYED_COMMAND_MESSAGE = "command already used (replay rejected)";
|
|
|
16
16
|
var INVALID_MESSAGE_ERROR = "invalid message";
|
|
17
17
|
|
|
18
18
|
// ../../packages/local-agent-protocol/src/protocol-version.ts
|
|
19
|
-
var PROTOCOL_VERSION =
|
|
19
|
+
var PROTOCOL_VERSION = 10;
|
|
20
20
|
|
|
21
21
|
// ../../packages/local-agent-protocol/src/project-path.ts
|
|
22
22
|
var PROJECT_PATH_INVALID_REASONS = [
|
|
@@ -28,32 +28,127 @@ var PROJECT_PATH_INVALID_REASONS = [
|
|
|
28
28
|
];
|
|
29
29
|
|
|
30
30
|
// ../../packages/local-agent-protocol/src/schemas.ts
|
|
31
|
-
import { z as
|
|
31
|
+
import { z as z3 } from "zod";
|
|
32
32
|
|
|
33
33
|
// ../../packages/local-agent-protocol/src/connection-challenge.ts
|
|
34
|
+
import { z as z2 } from "zod";
|
|
35
|
+
|
|
36
|
+
// ../../packages/local-agent-protocol/src/e2e-key.ts
|
|
34
37
|
import { z } from "zod";
|
|
38
|
+
|
|
39
|
+
// ../../packages/local-agent-protocol/src/canonicalize.ts
|
|
40
|
+
function canonicalize(value) {
|
|
41
|
+
return serialize(value);
|
|
42
|
+
}
|
|
43
|
+
function serialize(value) {
|
|
44
|
+
if (value === null || typeof value === "number" || typeof value === "boolean") {
|
|
45
|
+
return JSON.stringify(value);
|
|
46
|
+
}
|
|
47
|
+
if (typeof value === "string") {
|
|
48
|
+
return JSON.stringify(value);
|
|
49
|
+
}
|
|
50
|
+
if (value === void 0) {
|
|
51
|
+
throw new TypeError("canonicalize: undefined is not representable");
|
|
52
|
+
}
|
|
53
|
+
if (Array.isArray(value)) {
|
|
54
|
+
return `[${value.map(serialize).join(",")}]`;
|
|
55
|
+
}
|
|
56
|
+
if (typeof value === "object") {
|
|
57
|
+
const keys = Object.keys(value).sort();
|
|
58
|
+
const entries = keys.map((key) => {
|
|
59
|
+
const entryValue = value[key];
|
|
60
|
+
return `${JSON.stringify(key)}:${serialize(entryValue)}`;
|
|
61
|
+
});
|
|
62
|
+
return `{${entries.join(",")}}`;
|
|
63
|
+
}
|
|
64
|
+
throw new TypeError(`canonicalize: unsupported value type ${typeof value}`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ../../packages/local-agent-protocol/src/e2e-key.ts
|
|
68
|
+
var E2E_KEY_INFO = "bondsmith.e2e.v1";
|
|
69
|
+
var X25519_PUBLIC_KEY_LENGTH = 43;
|
|
70
|
+
var x25519PublicKeySchema = z.string().length(X25519_PUBLIC_KEY_LENGTH).regex(/^[A-Za-z0-9_-]+$/);
|
|
71
|
+
var KEY_BYTES = 32;
|
|
72
|
+
var encoder = new TextEncoder();
|
|
73
|
+
function toBase64Url(bytes) {
|
|
74
|
+
let binary = "";
|
|
75
|
+
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
76
|
+
return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");
|
|
77
|
+
}
|
|
78
|
+
function fromBase64Url(value) {
|
|
79
|
+
const binary = atob(value.replaceAll("-", "+").replaceAll("_", "/"));
|
|
80
|
+
const bytes = new Uint8Array(binary.length);
|
|
81
|
+
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
|
82
|
+
return bytes;
|
|
83
|
+
}
|
|
84
|
+
async function createEphemeralKeyExchange(role) {
|
|
85
|
+
const pair = await crypto.subtle.generateKey({ name: "X25519" }, false, [
|
|
86
|
+
"deriveBits"
|
|
87
|
+
]);
|
|
88
|
+
const publicKey = toBase64Url(
|
|
89
|
+
new Uint8Array(await crypto.subtle.exportKey("raw", pair.publicKey))
|
|
90
|
+
);
|
|
91
|
+
return {
|
|
92
|
+
publicKey,
|
|
93
|
+
deriveKey: async (peerPublicKey, transcript) => {
|
|
94
|
+
const { clientEcdhPublicKey, daemonEcdhPublicKey } = transcript;
|
|
95
|
+
const [ownKey, expectedPeerKey] = role === "client" ? [clientEcdhPublicKey, daemonEcdhPublicKey] : [daemonEcdhPublicKey, clientEcdhPublicKey];
|
|
96
|
+
if (ownKey !== publicKey || expectedPeerKey !== peerPublicKey) {
|
|
97
|
+
throw new Error("The signed transcript does not carry this connection's key pair");
|
|
98
|
+
}
|
|
99
|
+
const peerKey = await crypto.subtle.importKey(
|
|
100
|
+
"raw",
|
|
101
|
+
fromBase64Url(peerPublicKey),
|
|
102
|
+
{ name: "X25519" },
|
|
103
|
+
false,
|
|
104
|
+
[]
|
|
105
|
+
);
|
|
106
|
+
const secret = await crypto.subtle.deriveBits(
|
|
107
|
+
{ name: "X25519", public: peerKey },
|
|
108
|
+
pair.privateKey,
|
|
109
|
+
KEY_BYTES * 8
|
|
110
|
+
);
|
|
111
|
+
const hkdfKey = await crypto.subtle.importKey("raw", secret, "HKDF", false, ["deriveBits"]);
|
|
112
|
+
const salt = await crypto.subtle.digest("SHA-256", encoder.encode(canonicalize(transcript)));
|
|
113
|
+
return new Uint8Array(
|
|
114
|
+
await crypto.subtle.deriveBits(
|
|
115
|
+
{ name: "HKDF", hash: "SHA-256", salt, info: encoder.encode(E2E_KEY_INFO) },
|
|
116
|
+
hkdfKey,
|
|
117
|
+
KEY_BYTES * 8
|
|
118
|
+
)
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ../../packages/local-agent-protocol/src/connection-challenge.ts
|
|
35
125
|
var CONNECTION_CHALLENGE_PURPOSE = "bondsmith.connection-handshake";
|
|
36
|
-
var
|
|
37
|
-
|
|
38
|
-
|
|
126
|
+
var CLIENT_NONCE_MIN_LENGTH = 16;
|
|
127
|
+
var connectionChallengeTranscriptSchema = z2.object({
|
|
128
|
+
version: z2.literal(1),
|
|
129
|
+
purpose: z2.literal(CONNECTION_CHALLENGE_PURPOSE),
|
|
39
130
|
// Generated by the daemon per-socket, at connection time — never
|
|
40
131
|
// client-supplied, never reused across connections or reconnects.
|
|
41
|
-
connectionId:
|
|
132
|
+
connectionId: z2.string().min(1),
|
|
42
133
|
// Fresh, cryptographically random, single-use — a second handshake
|
|
43
134
|
// attempt (even with a validly-signed transcript for the same
|
|
44
135
|
// connectionId) is rejected by the daemon outright.
|
|
45
|
-
challenge:
|
|
46
|
-
daemonFingerprint:
|
|
47
|
-
controllerId:
|
|
136
|
+
challenge: z2.string().min(1),
|
|
137
|
+
daemonFingerprint: z2.string().min(1),
|
|
138
|
+
controllerId: z2.string().min(1),
|
|
48
139
|
// An assertion the browser makes, not independent account
|
|
49
140
|
// authentication — the daemon has no account-token layer to verify this
|
|
50
141
|
// against (unchanged from ADR-012's existing caveat on CommandEnvelope's
|
|
51
142
|
// own accountId field).
|
|
52
|
-
accountId:
|
|
143
|
+
accountId: z2.number().int().positive(),
|
|
144
|
+
// Fresh per connection, chosen by the browser: the daemon signs it back (daemon-proof.ts) so the browser knows the daemon answered THIS handshake, not a replay. Absent from older browsers.
|
|
145
|
+
clientNonce: z2.string().min(CLIENT_NONCE_MIN_LENGTH).optional(),
|
|
146
|
+
// Fresh per connection: the browser's half of the key agreement (e2e-key.ts), signed here so the relay cannot swap it.
|
|
147
|
+
clientEcdhPublicKey: x25519PublicKeySchema.optional()
|
|
53
148
|
});
|
|
54
|
-
var signedConnectionChallengeSchema =
|
|
149
|
+
var signedConnectionChallengeSchema = z2.object({
|
|
55
150
|
transcript: connectionChallengeTranscriptSchema,
|
|
56
|
-
signature:
|
|
151
|
+
signature: z2.string().min(1)
|
|
57
152
|
});
|
|
58
153
|
|
|
59
154
|
// ../../packages/local-agent-protocol/src/schemas.ts
|
|
@@ -64,77 +159,77 @@ var MAX_DAEMON_REPLY_BYTES = 200 * 1024;
|
|
|
64
159
|
var MAX_RANGE_LINES = 500;
|
|
65
160
|
var MAX_PLAN_CHARS = 32 * 1024;
|
|
66
161
|
var MAX_TERMINAL_DIMENSION = 1e3;
|
|
67
|
-
var terminalSizeSchema =
|
|
68
|
-
cols:
|
|
69
|
-
rows:
|
|
162
|
+
var terminalSizeSchema = z3.object({
|
|
163
|
+
cols: z3.number().int().min(1).max(MAX_TERMINAL_DIMENSION),
|
|
164
|
+
rows: z3.number().int().min(1).max(MAX_TERMINAL_DIMENSION)
|
|
70
165
|
});
|
|
71
|
-
var localSessionSchema =
|
|
72
|
-
id:
|
|
73
|
-
title:
|
|
74
|
-
command:
|
|
75
|
-
cwd:
|
|
76
|
-
tmuxSessionName:
|
|
77
|
-
status:
|
|
78
|
-
startedAt:
|
|
79
|
-
branch:
|
|
80
|
-
repositoryFullName:
|
|
81
|
-
lastActivityAt:
|
|
82
|
-
changedFileCount:
|
|
83
|
-
worktreeOf:
|
|
166
|
+
var localSessionSchema = z3.object({
|
|
167
|
+
id: z3.string(),
|
|
168
|
+
title: z3.string(),
|
|
169
|
+
command: z3.string(),
|
|
170
|
+
cwd: z3.string(),
|
|
171
|
+
tmuxSessionName: z3.string(),
|
|
172
|
+
status: z3.enum(SESSION_STATUSES),
|
|
173
|
+
startedAt: z3.string(),
|
|
174
|
+
branch: z3.string().nullable(),
|
|
175
|
+
repositoryFullName: z3.string().nullable(),
|
|
176
|
+
lastActivityAt: z3.string().nullable(),
|
|
177
|
+
changedFileCount: z3.number().int().nonnegative().nullable(),
|
|
178
|
+
worktreeOf: z3.string().nullable(),
|
|
84
179
|
// .catch(undefined): a state this version doesn't know must never drop the whole session message.
|
|
85
|
-
agentState:
|
|
180
|
+
agentState: z3.enum(AGENT_STATES).nullable().optional().catch(void 0)
|
|
86
181
|
});
|
|
87
|
-
var workspaceFileEntrySchema =
|
|
88
|
-
path:
|
|
89
|
-
previousPath:
|
|
90
|
-
status:
|
|
91
|
-
staged:
|
|
92
|
-
additions:
|
|
93
|
-
deletions:
|
|
94
|
-
binary:
|
|
182
|
+
var workspaceFileEntrySchema = z3.object({
|
|
183
|
+
path: z3.string(),
|
|
184
|
+
previousPath: z3.string().optional(),
|
|
185
|
+
status: z3.enum(["added", "modified", "deleted", "renamed", "copied", "untracked"]),
|
|
186
|
+
staged: z3.boolean(),
|
|
187
|
+
additions: z3.number().int().nonnegative().nullable(),
|
|
188
|
+
deletions: z3.number().int().nonnegative().nullable(),
|
|
189
|
+
binary: z3.boolean()
|
|
95
190
|
});
|
|
96
|
-
var workspaceStatusSchema =
|
|
97
|
-
isGitRepo:
|
|
98
|
-
branch:
|
|
191
|
+
var workspaceStatusSchema = z3.object({
|
|
192
|
+
isGitRepo: z3.boolean(),
|
|
193
|
+
branch: z3.string().nullable(),
|
|
99
194
|
// .catch(undefined): a malformed informational field must never drop the whole status message.
|
|
100
|
-
baseRef:
|
|
101
|
-
files:
|
|
102
|
-
totalFiles:
|
|
195
|
+
baseRef: z3.string().nullable().optional().catch(void 0),
|
|
196
|
+
files: z3.array(workspaceFileEntrySchema),
|
|
197
|
+
totalFiles: z3.number().int().nonnegative().optional().catch(void 0)
|
|
103
198
|
});
|
|
104
|
-
var workspaceFileDiffSchema =
|
|
105
|
-
path:
|
|
106
|
-
staged:
|
|
107
|
-
patch:
|
|
108
|
-
binary:
|
|
109
|
-
tooLarge:
|
|
110
|
-
version:
|
|
199
|
+
var workspaceFileDiffSchema = z3.object({
|
|
200
|
+
path: z3.string(),
|
|
201
|
+
staged: z3.boolean(),
|
|
202
|
+
patch: z3.string().nullable(),
|
|
203
|
+
binary: z3.boolean(),
|
|
204
|
+
tooLarge: z3.boolean().optional(),
|
|
205
|
+
version: z3.string().optional()
|
|
111
206
|
});
|
|
112
|
-
var workspaceFileRangeSchema =
|
|
113
|
-
|
|
114
|
-
ok:
|
|
115
|
-
path:
|
|
116
|
-
startLine:
|
|
117
|
-
lines:
|
|
118
|
-
totalLines:
|
|
119
|
-
version:
|
|
207
|
+
var workspaceFileRangeSchema = z3.discriminatedUnion("ok", [
|
|
208
|
+
z3.object({
|
|
209
|
+
ok: z3.literal(true),
|
|
210
|
+
path: z3.string(),
|
|
211
|
+
startLine: z3.number().int().min(1),
|
|
212
|
+
lines: z3.array(z3.string()).max(MAX_RANGE_LINES),
|
|
213
|
+
totalLines: z3.number().int().nonnegative(),
|
|
214
|
+
version: z3.string()
|
|
120
215
|
}),
|
|
121
|
-
|
|
122
|
-
ok:
|
|
123
|
-
path:
|
|
124
|
-
reason:
|
|
216
|
+
z3.object({
|
|
217
|
+
ok: z3.literal(false),
|
|
218
|
+
path: z3.string(),
|
|
219
|
+
reason: z3.enum(["changed", "missing", "binary", "too_large", "not_allowed"])
|
|
125
220
|
})
|
|
126
221
|
]);
|
|
127
|
-
var projectPathValidationSchema =
|
|
128
|
-
|
|
129
|
-
|
|
222
|
+
var projectPathValidationSchema = z3.discriminatedUnion("valid", [
|
|
223
|
+
z3.object({ valid: z3.literal(true), path: z3.string() }),
|
|
224
|
+
z3.object({ valid: z3.literal(false), reason: z3.enum(PROJECT_PATH_INVALID_REASONS) })
|
|
130
225
|
]);
|
|
131
|
-
var browserMessageSchema =
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
type:
|
|
135
|
-
title:
|
|
136
|
-
cwd:
|
|
137
|
-
command:
|
|
226
|
+
var browserMessageSchema = z3.discriminatedUnion("type", [
|
|
227
|
+
z3.object({ type: z3.literal("session.list") }),
|
|
228
|
+
z3.object({
|
|
229
|
+
type: z3.literal("session.create"),
|
|
230
|
+
title: z3.string().min(1).max(200),
|
|
231
|
+
cwd: z3.string().min(1),
|
|
232
|
+
command: z3.string().min(1).max(2e3),
|
|
138
233
|
// Opt-in worktree creation — see messages.ts's own field comment.
|
|
139
234
|
// Most branch-name legality is left to git itself in createWorktree()
|
|
140
235
|
// (whose named error classes surface a rejection back to the
|
|
@@ -144,14 +239,14 @@ var browserMessageSchema = z2.discriminatedUnion("type", [
|
|
|
144
239
|
// the error instead of a clean, classified one (confirmed live
|
|
145
240
|
// during adversarial review) — rejected here instead, before it ever
|
|
146
241
|
// reaches git.
|
|
147
|
-
worktreeBranch:
|
|
242
|
+
worktreeBranch: z3.string().min(1).max(250).refine((value) => !value.startsWith("-"), {
|
|
148
243
|
message: "branch name can't start with -"
|
|
149
244
|
}).optional(),
|
|
150
|
-
requestId:
|
|
245
|
+
requestId: z3.string().min(1).max(200).optional()
|
|
151
246
|
}),
|
|
152
|
-
|
|
153
|
-
type:
|
|
154
|
-
sessionId:
|
|
247
|
+
z3.object({
|
|
248
|
+
type: z3.literal("session.attach"),
|
|
249
|
+
sessionId: z3.string(),
|
|
155
250
|
// Optional (not required) so an older daemon build, or a stale queued
|
|
156
251
|
// envelope signed before this field existed, still parses — see
|
|
157
252
|
// session.attached/error's own requestId fields for why this matters:
|
|
@@ -161,187 +256,192 @@ var browserMessageSchema = z2.discriminatedUnion("type", [
|
|
|
161
256
|
// misattributes a daemon error for ANY other in-flight command
|
|
162
257
|
// (observed: a session.resize racing ahead of its own session.attach)
|
|
163
258
|
// to the wrong promise — a confirmed bug, not a hypothetical.
|
|
164
|
-
requestId:
|
|
259
|
+
requestId: z3.string().min(1).max(200).optional(),
|
|
165
260
|
// Tolerant like baseRef: an unreadable size must not make the daemon drop the attach itself.
|
|
166
261
|
size: terminalSizeSchema.optional().catch(void 0)
|
|
167
262
|
}),
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
type:
|
|
171
|
-
sessionId:
|
|
172
|
-
data:
|
|
263
|
+
z3.object({ type: z3.literal("session.detach"), sessionId: z3.string() }),
|
|
264
|
+
z3.object({
|
|
265
|
+
type: z3.literal("session.input"),
|
|
266
|
+
sessionId: z3.string(),
|
|
267
|
+
data: z3.string().max(MAX_INPUT_CHARS)
|
|
173
268
|
}),
|
|
174
|
-
|
|
175
|
-
type:
|
|
176
|
-
sessionId:
|
|
177
|
-
text:
|
|
269
|
+
z3.object({
|
|
270
|
+
type: z3.literal("session.insertText"),
|
|
271
|
+
sessionId: z3.string(),
|
|
272
|
+
text: z3.string().max(MAX_INSERT_TEXT_BYTES)
|
|
178
273
|
}),
|
|
179
|
-
|
|
180
|
-
type:
|
|
181
|
-
sessionId:
|
|
274
|
+
z3.object({
|
|
275
|
+
type: z3.literal("session.resize"),
|
|
276
|
+
sessionId: z3.string(),
|
|
182
277
|
...terminalSizeSchema.shape
|
|
183
278
|
}),
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
type:
|
|
187
|
-
sessionId:
|
|
188
|
-
requestId:
|
|
279
|
+
z3.object({ type: z3.literal("session.stop"), sessionId: z3.string() }),
|
|
280
|
+
z3.object({
|
|
281
|
+
type: z3.literal("workspace.getStatus"),
|
|
282
|
+
sessionId: z3.string(),
|
|
283
|
+
requestId: z3.string().min(1).max(200)
|
|
189
284
|
}),
|
|
190
|
-
|
|
191
|
-
type:
|
|
192
|
-
sessionId:
|
|
193
|
-
path:
|
|
194
|
-
startLine:
|
|
195
|
-
lineCount:
|
|
196
|
-
version:
|
|
197
|
-
requestId:
|
|
285
|
+
z3.object({
|
|
286
|
+
type: z3.literal("workspace.readFileRange"),
|
|
287
|
+
sessionId: z3.string(),
|
|
288
|
+
path: z3.string().min(1).max(4096),
|
|
289
|
+
startLine: z3.number().int().min(1),
|
|
290
|
+
lineCount: z3.number().int().min(1).max(MAX_RANGE_LINES),
|
|
291
|
+
version: z3.string().min(1).max(100),
|
|
292
|
+
requestId: z3.string().min(1).max(200)
|
|
198
293
|
}),
|
|
199
|
-
|
|
200
|
-
type:
|
|
201
|
-
sessionId:
|
|
202
|
-
path:
|
|
203
|
-
staged:
|
|
204
|
-
requestId:
|
|
294
|
+
z3.object({
|
|
295
|
+
type: z3.literal("workspace.getDiff"),
|
|
296
|
+
sessionId: z3.string(),
|
|
297
|
+
path: z3.string().min(1).max(4096),
|
|
298
|
+
staged: z3.boolean(),
|
|
299
|
+
requestId: z3.string().min(1).max(200)
|
|
205
300
|
}),
|
|
206
|
-
|
|
207
|
-
type:
|
|
208
|
-
sessionId:
|
|
209
|
-
requestId:
|
|
301
|
+
z3.object({
|
|
302
|
+
type: z3.literal("workspace.getPlan"),
|
|
303
|
+
sessionId: z3.string(),
|
|
304
|
+
requestId: z3.string().min(1).max(200)
|
|
210
305
|
}),
|
|
211
|
-
|
|
212
|
-
type:
|
|
213
|
-
path:
|
|
214
|
-
requestId:
|
|
306
|
+
z3.object({
|
|
307
|
+
type: z3.literal("project.validatePath"),
|
|
308
|
+
path: z3.string().min(1).max(4096),
|
|
309
|
+
requestId: z3.string().min(1).max(200)
|
|
215
310
|
}),
|
|
216
|
-
|
|
311
|
+
z3.object({ type: z3.literal("daemon.getInfo") }),
|
|
217
312
|
// Second-controller enrollment (ADR-011/ADR-012, BD-433) — deliberately
|
|
218
313
|
// unsigned: the requesting browser has no trusted key yet to sign with.
|
|
219
314
|
// Authority comes entirely from explicit daemon-local user approval, not
|
|
220
315
|
// from anything in this message.
|
|
221
|
-
|
|
222
|
-
type:
|
|
223
|
-
publicKeyPem:
|
|
224
|
-
label:
|
|
316
|
+
z3.object({
|
|
317
|
+
type: z3.literal("controller.requestEnrollment"),
|
|
318
|
+
publicKeyPem: z3.string().min(1),
|
|
319
|
+
label: z3.string().min(1).max(200)
|
|
225
320
|
}),
|
|
226
321
|
// ADR-013/BD-477: the browser's signed response to connection.challenge —
|
|
227
322
|
// carries its own signed transcript, not a CommandEnvelope, so it's a
|
|
228
323
|
// distinct BrowserMessage variant rather than a SIGNABLE_ACTIONS entry.
|
|
229
|
-
|
|
230
|
-
type:
|
|
231
|
-
signed:
|
|
324
|
+
z3.object({
|
|
325
|
+
type: z3.literal("connection.authenticate"),
|
|
326
|
+
signed: z3.object({
|
|
232
327
|
transcript: connectionChallengeTranscriptSchema,
|
|
233
|
-
signature:
|
|
328
|
+
signature: z3.string().min(1)
|
|
234
329
|
})
|
|
235
330
|
})
|
|
236
331
|
]);
|
|
237
|
-
var connectionAuthenticationFailureReasonSchema =
|
|
332
|
+
var connectionAuthenticationFailureReasonSchema = z3.enum([
|
|
238
333
|
"bad_signature",
|
|
239
334
|
"untrusted_controller",
|
|
240
335
|
"wrong_daemon",
|
|
241
336
|
"wrong_connection",
|
|
242
337
|
"challenge_expired",
|
|
243
338
|
"already_authenticated",
|
|
244
|
-
"rate_limited"
|
|
339
|
+
"rate_limited",
|
|
340
|
+
"key_agreement_failed"
|
|
245
341
|
]);
|
|
246
|
-
var connectionRejectionReasonSchema =
|
|
247
|
-
var daemonMessageSchema =
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
type:
|
|
342
|
+
var connectionRejectionReasonSchema = z3.enum(["origin_not_allowed", "token_invalid"]);
|
|
343
|
+
var daemonMessageSchema = z3.discriminatedUnion("type", [
|
|
344
|
+
z3.object({ type: z3.literal("session.list"), sessions: z3.array(localSessionSchema) }),
|
|
345
|
+
z3.object({
|
|
346
|
+
type: z3.literal("session.created"),
|
|
251
347
|
session: localSessionSchema,
|
|
252
|
-
requestId:
|
|
348
|
+
requestId: z3.string().optional()
|
|
253
349
|
}),
|
|
254
|
-
|
|
255
|
-
type:
|
|
256
|
-
sessionId:
|
|
257
|
-
data:
|
|
350
|
+
z3.object({
|
|
351
|
+
type: z3.literal("terminal.output"),
|
|
352
|
+
sessionId: z3.string(),
|
|
353
|
+
data: z3.string()
|
|
258
354
|
}),
|
|
259
|
-
|
|
355
|
+
z3.object({ type: z3.literal("session.updated"), session: localSessionSchema }),
|
|
260
356
|
// Ack for a successful session.attach — the browser must wait for this
|
|
261
357
|
// before wiring up terminal input, or a keystroke sent before the daemon
|
|
262
358
|
// actually finishes attaching gets rejected as "not attached to
|
|
263
359
|
// session" (observed during a reconnect: attachSession() was
|
|
264
360
|
// fire-and-forget, so terminal.onData could fire before attach
|
|
265
361
|
// completed).
|
|
266
|
-
|
|
267
|
-
type:
|
|
268
|
-
sessionId:
|
|
269
|
-
requestId:
|
|
362
|
+
z3.object({
|
|
363
|
+
type: z3.literal("session.attached"),
|
|
364
|
+
sessionId: z3.string(),
|
|
365
|
+
requestId: z3.string().optional()
|
|
270
366
|
}),
|
|
271
|
-
|
|
272
|
-
type:
|
|
273
|
-
message:
|
|
367
|
+
z3.object({
|
|
368
|
+
type: z3.literal("error"),
|
|
369
|
+
message: z3.string(),
|
|
274
370
|
// Echoes the triggering message's requestId when it had one, so the
|
|
275
371
|
// browser can match this error to the exact pending request instead
|
|
276
372
|
// of guessing "oldest pending create, then oldest pending attach" —
|
|
277
373
|
// undefined for error paths with no requestId to echo (most session.*
|
|
278
374
|
// commands still don't carry one; see session.attach above for the
|
|
279
375
|
// one this was added to fix).
|
|
280
|
-
requestId:
|
|
376
|
+
requestId: z3.string().optional()
|
|
281
377
|
}),
|
|
282
|
-
|
|
283
|
-
type:
|
|
284
|
-
sessionId:
|
|
285
|
-
requestId:
|
|
378
|
+
z3.object({
|
|
379
|
+
type: z3.literal("workspace.status"),
|
|
380
|
+
sessionId: z3.string(),
|
|
381
|
+
requestId: z3.string(),
|
|
286
382
|
status: workspaceStatusSchema
|
|
287
383
|
}),
|
|
288
|
-
|
|
289
|
-
type:
|
|
290
|
-
sessionId:
|
|
291
|
-
requestId:
|
|
384
|
+
z3.object({
|
|
385
|
+
type: z3.literal("workspace.fileRange"),
|
|
386
|
+
sessionId: z3.string(),
|
|
387
|
+
requestId: z3.string(),
|
|
292
388
|
range: workspaceFileRangeSchema
|
|
293
389
|
}),
|
|
294
|
-
|
|
295
|
-
type:
|
|
296
|
-
sessionId:
|
|
297
|
-
requestId:
|
|
390
|
+
z3.object({
|
|
391
|
+
type: z3.literal("workspace.diff"),
|
|
392
|
+
sessionId: z3.string(),
|
|
393
|
+
requestId: z3.string(),
|
|
298
394
|
diff: workspaceFileDiffSchema
|
|
299
395
|
}),
|
|
300
|
-
|
|
301
|
-
type:
|
|
302
|
-
sessionId:
|
|
303
|
-
requestId:
|
|
304
|
-
plan:
|
|
396
|
+
z3.object({
|
|
397
|
+
type: z3.literal("workspace.plan"),
|
|
398
|
+
sessionId: z3.string(),
|
|
399
|
+
requestId: z3.string(),
|
|
400
|
+
plan: z3.string().max(MAX_PLAN_CHARS).nullable()
|
|
305
401
|
}),
|
|
306
|
-
|
|
307
|
-
type:
|
|
308
|
-
requestId:
|
|
402
|
+
z3.object({
|
|
403
|
+
type: z3.literal("project.pathValidated"),
|
|
404
|
+
requestId: z3.string(),
|
|
309
405
|
result: projectPathValidationSchema
|
|
310
406
|
}),
|
|
311
|
-
|
|
312
|
-
type:
|
|
313
|
-
hostname:
|
|
314
|
-
daemonFingerprint:
|
|
407
|
+
z3.object({
|
|
408
|
+
type: z3.literal("daemon.info"),
|
|
409
|
+
hostname: z3.string(),
|
|
410
|
+
daemonFingerprint: z3.string()
|
|
315
411
|
}),
|
|
316
|
-
|
|
317
|
-
type:
|
|
318
|
-
fingerprint:
|
|
412
|
+
z3.object({
|
|
413
|
+
type: z3.literal("controller.enrollmentPending"),
|
|
414
|
+
fingerprint: z3.string()
|
|
319
415
|
}),
|
|
320
|
-
|
|
321
|
-
type:
|
|
322
|
-
reason:
|
|
416
|
+
z3.object({
|
|
417
|
+
type: z3.literal("controller.enrollmentRejected"),
|
|
418
|
+
reason: z3.enum(["not_interactive", "rate_limited"])
|
|
323
419
|
}),
|
|
324
|
-
|
|
325
|
-
type:
|
|
326
|
-
connectionId:
|
|
327
|
-
challenge:
|
|
328
|
-
daemonFingerprint:
|
|
329
|
-
hostname:
|
|
330
|
-
expiresAt:
|
|
420
|
+
z3.object({
|
|
421
|
+
type: z3.literal("connection.challenge"),
|
|
422
|
+
connectionId: z3.string().min(1),
|
|
423
|
+
challenge: z3.string().min(1),
|
|
424
|
+
daemonFingerprint: z3.string().min(1),
|
|
425
|
+
hostname: z3.string(),
|
|
426
|
+
expiresAt: z3.number().int().positive(),
|
|
331
427
|
// .catch(undefined): a malformed informational field must never fail the whole handshake message.
|
|
332
|
-
protocolVersion:
|
|
333
|
-
daemonVersion:
|
|
428
|
+
protocolVersion: z3.number().int().positive().optional().catch(void 0),
|
|
429
|
+
daemonVersion: z3.string().optional().catch(void 0)
|
|
334
430
|
}),
|
|
335
|
-
|
|
336
|
-
type:
|
|
337
|
-
connectionId:
|
|
431
|
+
z3.object({
|
|
432
|
+
type: z3.literal("connection.authenticated"),
|
|
433
|
+
connectionId: z3.string().min(1),
|
|
434
|
+
// Strict on purpose (no .catch): a corrupted signature must fail the message, not quietly read as "unsigned".
|
|
435
|
+
daemonSignature: z3.string().min(1).optional(),
|
|
436
|
+
// The daemon's half of the key agreement; covered by daemonSignature.
|
|
437
|
+
daemonEcdhPublicKey: x25519PublicKeySchema.optional()
|
|
338
438
|
}),
|
|
339
|
-
|
|
340
|
-
type:
|
|
439
|
+
z3.object({
|
|
440
|
+
type: z3.literal("connection.authenticationFailed"),
|
|
341
441
|
reason: connectionAuthenticationFailureReasonSchema
|
|
342
442
|
}),
|
|
343
|
-
|
|
344
|
-
type:
|
|
443
|
+
z3.object({
|
|
444
|
+
type: z3.literal("connection.rejected"),
|
|
345
445
|
reason: connectionRejectionReasonSchema
|
|
346
446
|
})
|
|
347
447
|
]);
|
|
@@ -376,36 +476,8 @@ async function controllerIdFromPublicKeyPem(publicKeyPem) {
|
|
|
376
476
|
return hex.slice(0, 32);
|
|
377
477
|
}
|
|
378
478
|
|
|
379
|
-
// ../../packages/local-agent-protocol/src/canonicalize.ts
|
|
380
|
-
function canonicalize(value) {
|
|
381
|
-
return serialize(value);
|
|
382
|
-
}
|
|
383
|
-
function serialize(value) {
|
|
384
|
-
if (value === null || typeof value === "number" || typeof value === "boolean") {
|
|
385
|
-
return JSON.stringify(value);
|
|
386
|
-
}
|
|
387
|
-
if (typeof value === "string") {
|
|
388
|
-
return JSON.stringify(value);
|
|
389
|
-
}
|
|
390
|
-
if (value === void 0) {
|
|
391
|
-
throw new TypeError("canonicalize: undefined is not representable");
|
|
392
|
-
}
|
|
393
|
-
if (Array.isArray(value)) {
|
|
394
|
-
return `[${value.map(serialize).join(",")}]`;
|
|
395
|
-
}
|
|
396
|
-
if (typeof value === "object") {
|
|
397
|
-
const keys = Object.keys(value).sort();
|
|
398
|
-
const entries = keys.map((key) => {
|
|
399
|
-
const entryValue = value[key];
|
|
400
|
-
return `${JSON.stringify(key)}:${serialize(entryValue)}`;
|
|
401
|
-
});
|
|
402
|
-
return `{${entries.join(",")}}`;
|
|
403
|
-
}
|
|
404
|
-
throw new TypeError(`canonicalize: unsupported value type ${typeof value}`);
|
|
405
|
-
}
|
|
406
|
-
|
|
407
479
|
// ../../packages/local-agent-protocol/src/envelope.ts
|
|
408
|
-
import { z as
|
|
480
|
+
import { z as z4 } from "zod";
|
|
409
481
|
var SIGNABLE_ACTIONS = [
|
|
410
482
|
"session.create",
|
|
411
483
|
"session.attach",
|
|
@@ -421,113 +493,145 @@ var SIGNABLE_ACTIONS = [
|
|
|
421
493
|
"workspace.getPlan",
|
|
422
494
|
"project.validatePath"
|
|
423
495
|
];
|
|
424
|
-
var envelopeSchema =
|
|
425
|
-
version:
|
|
426
|
-
controllerId:
|
|
427
|
-
accountId:
|
|
428
|
-
daemonId:
|
|
496
|
+
var envelopeSchema = z4.object({
|
|
497
|
+
version: z4.literal(1),
|
|
498
|
+
controllerId: z4.string().min(1),
|
|
499
|
+
accountId: z4.number().int().positive(),
|
|
500
|
+
daemonId: z4.string().min(1),
|
|
429
501
|
// The specific WebSocket connection this envelope was signed for (ADR-013,
|
|
430
502
|
// BD-477) — issued by the daemon during that connection's handshake
|
|
431
503
|
// (connection-challenge.ts), never reused across connections. `sequence`
|
|
432
504
|
// below is only ever compared against other envelopes carrying the SAME
|
|
433
505
|
// connectionId; an envelope for a stale or foreign connectionId is
|
|
434
506
|
// rejected before sequence is even considered (verify-command.ts).
|
|
435
|
-
connectionId:
|
|
436
|
-
sessionId:
|
|
437
|
-
action:
|
|
438
|
-
payload:
|
|
439
|
-
issuedAt:
|
|
440
|
-
expiresAt:
|
|
507
|
+
connectionId: z4.string().min(1),
|
|
508
|
+
sessionId: z4.string().nullable(),
|
|
509
|
+
action: z4.enum(SIGNABLE_ACTIONS),
|
|
510
|
+
payload: z4.unknown(),
|
|
511
|
+
issuedAt: z4.number().int().positive(),
|
|
512
|
+
expiresAt: z4.number().int().positive(),
|
|
441
513
|
// Strictly increasing WITHIN one connectionId's sequence space, starting
|
|
442
514
|
// at 1 for every new authenticated connection — never a persisted,
|
|
443
515
|
// global-per-controller counter (that design broke on page reload; see
|
|
444
516
|
// ADR-013). Verified and advanced in-memory, per connection, never
|
|
445
517
|
// written to disk.
|
|
446
|
-
sequence:
|
|
518
|
+
sequence: z4.number().int().positive()
|
|
447
519
|
});
|
|
448
|
-
var signedCommandSchema =
|
|
520
|
+
var signedCommandSchema = z4.object({
|
|
449
521
|
envelope: envelopeSchema,
|
|
450
|
-
signature:
|
|
522
|
+
signature: z4.string().min(1)
|
|
451
523
|
});
|
|
452
524
|
|
|
453
525
|
// ../../packages/local-agent-protocol/src/pairing-challenge.ts
|
|
454
|
-
import { z as
|
|
526
|
+
import { z as z5 } from "zod";
|
|
455
527
|
var PAIRING_CHALLENGE_PURPOSE = "bondsmith.initial-controller-pairing";
|
|
456
|
-
var pairingChallengeTranscriptSchema =
|
|
457
|
-
version:
|
|
458
|
-
purpose:
|
|
459
|
-
pairingAttemptId:
|
|
460
|
-
challenge:
|
|
461
|
-
daemonFingerprint:
|
|
462
|
-
controllerPublicKeyPem:
|
|
463
|
-
accountId:
|
|
528
|
+
var pairingChallengeTranscriptSchema = z5.object({
|
|
529
|
+
version: z5.literal(1),
|
|
530
|
+
purpose: z5.literal(PAIRING_CHALLENGE_PURPOSE),
|
|
531
|
+
pairingAttemptId: z5.string().min(1),
|
|
532
|
+
challenge: z5.string().min(1),
|
|
533
|
+
daemonFingerprint: z5.string().min(1),
|
|
534
|
+
controllerPublicKeyPem: z5.string().min(1),
|
|
535
|
+
accountId: z5.number().int().positive()
|
|
464
536
|
});
|
|
465
|
-
var signedPairingChallengeSchema =
|
|
537
|
+
var signedPairingChallengeSchema = z5.object({
|
|
466
538
|
transcript: pairingChallengeTranscriptSchema,
|
|
467
|
-
signature:
|
|
539
|
+
signature: z5.string().min(1)
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
// ../../packages/local-agent-protocol/src/daemon-proof.ts
|
|
543
|
+
import { z as z6 } from "zod";
|
|
544
|
+
var DAEMON_PROOF_PURPOSE = "bondsmith.daemon-proof";
|
|
545
|
+
var daemonProofTranscriptSchema = z6.object({
|
|
546
|
+
version: z6.literal(1),
|
|
547
|
+
purpose: z6.literal(DAEMON_PROOF_PURPOSE),
|
|
548
|
+
connectionId: z6.string().min(1),
|
|
549
|
+
challenge: z6.string().min(1),
|
|
550
|
+
daemonFingerprint: z6.string().min(1),
|
|
551
|
+
controllerId: z6.string().min(1),
|
|
552
|
+
accountId: z6.number().int().positive(),
|
|
553
|
+
clientNonce: z6.string().min(CLIENT_NONCE_MIN_LENGTH),
|
|
554
|
+
// Both halves of the key agreement (e2e-key.ts), so the daemon's signature vouches for the key the browser derives from.
|
|
555
|
+
clientEcdhPublicKey: x25519PublicKeySchema.optional(),
|
|
556
|
+
daemonEcdhPublicKey: x25519PublicKeySchema.optional()
|
|
468
557
|
});
|
|
558
|
+
function buildDaemonProofTranscript(handshake, daemonEcdhPublicKey) {
|
|
559
|
+
if (handshake.clientNonce === void 0) return null;
|
|
560
|
+
return {
|
|
561
|
+
version: 1,
|
|
562
|
+
purpose: DAEMON_PROOF_PURPOSE,
|
|
563
|
+
connectionId: handshake.connectionId,
|
|
564
|
+
challenge: handshake.challenge,
|
|
565
|
+
daemonFingerprint: handshake.daemonFingerprint,
|
|
566
|
+
controllerId: handshake.controllerId,
|
|
567
|
+
accountId: handshake.accountId,
|
|
568
|
+
clientNonce: handshake.clientNonce,
|
|
569
|
+
...handshake.clientEcdhPublicKey && { clientEcdhPublicKey: handshake.clientEcdhPublicKey },
|
|
570
|
+
...daemonEcdhPublicKey && { daemonEcdhPublicKey }
|
|
571
|
+
};
|
|
572
|
+
}
|
|
469
573
|
|
|
470
574
|
// ../../packages/local-agent-protocol/src/relay-register-transcript.ts
|
|
471
|
-
import { z as
|
|
575
|
+
import { z as z7 } from "zod";
|
|
472
576
|
var RELAY_REGISTER_PURPOSE = "bondsmith.relay.register";
|
|
473
|
-
var relayRegisterTranscriptSchema =
|
|
474
|
-
version:
|
|
475
|
-
purpose:
|
|
577
|
+
var relayRegisterTranscriptSchema = z7.object({
|
|
578
|
+
version: z7.literal(1),
|
|
579
|
+
purpose: z7.literal(RELAY_REGISTER_PURPOSE),
|
|
476
580
|
// Relay-issued, single-use — a second registration attempt against the
|
|
477
581
|
// same challenge is meaningless since a daemon reconnecting always gets
|
|
478
582
|
// a fresh challenge from a fresh relay.challenge message; this field
|
|
479
583
|
// exists so a captured signed transcript can't be replayed later against
|
|
480
584
|
// a still-open relay listener.
|
|
481
|
-
challenge:
|
|
482
|
-
daemonFingerprint:
|
|
483
|
-
issuedAt:
|
|
484
|
-
expiresAt:
|
|
585
|
+
challenge: z7.string().min(1),
|
|
586
|
+
daemonFingerprint: z7.string().min(1),
|
|
587
|
+
issuedAt: z7.number().int().positive(),
|
|
588
|
+
expiresAt: z7.number().int().positive(),
|
|
485
589
|
// Binds this transcript to one specific relay deployment (e.g.
|
|
486
590
|
// "relay.bondsmith.dev") — same reasoning as CommandEnvelope's own
|
|
487
591
|
// daemonId field binding a command to one specific daemon; prevents a
|
|
488
592
|
// transcript signed for one relay audience from being replayed against
|
|
489
593
|
// a different one.
|
|
490
|
-
relayAudience:
|
|
594
|
+
relayAudience: z7.string().min(1)
|
|
491
595
|
});
|
|
492
|
-
var signedRelayRegisterSchema =
|
|
596
|
+
var signedRelayRegisterSchema = z7.object({
|
|
493
597
|
// The daemon's full public key PEM, transmitted alongside the transcript
|
|
494
598
|
// so the relay can independently derive daemonFingerprint from it and
|
|
495
599
|
// verify the signature against it — see this file's own doc comment for
|
|
496
600
|
// why this must travel on the wire rather than being assumed.
|
|
497
|
-
daemonPublicKeyPem:
|
|
601
|
+
daemonPublicKeyPem: z7.string().min(1),
|
|
498
602
|
transcript: relayRegisterTranscriptSchema,
|
|
499
|
-
signature:
|
|
603
|
+
signature: z7.string().min(1)
|
|
500
604
|
});
|
|
501
605
|
|
|
502
606
|
// ../../packages/local-agent-protocol/src/relay-ticket.ts
|
|
503
|
-
import { z as
|
|
607
|
+
import { z as z8 } from "zod";
|
|
504
608
|
var RELAY_TICKET_PURPOSE = "bondsmith.relay.attach";
|
|
505
|
-
var relayTicketSchema =
|
|
506
|
-
version:
|
|
507
|
-
purpose:
|
|
508
|
-
keyId:
|
|
509
|
-
accountId:
|
|
510
|
-
userId:
|
|
511
|
-
daemonFingerprint:
|
|
512
|
-
issuedAt:
|
|
609
|
+
var relayTicketSchema = z8.object({
|
|
610
|
+
version: z8.literal(1),
|
|
611
|
+
purpose: z8.literal(RELAY_TICKET_PURPOSE),
|
|
612
|
+
keyId: z8.string().min(1),
|
|
613
|
+
accountId: z8.number().int().positive(),
|
|
614
|
+
userId: z8.string().min(1),
|
|
615
|
+
daemonFingerprint: z8.string().min(1),
|
|
616
|
+
issuedAt: z8.number().int().positive(),
|
|
513
617
|
// Deliberately short — single-digit minutes, enough for one attach
|
|
514
618
|
// attempt, not a durable credential.
|
|
515
|
-
expiresAt:
|
|
619
|
+
expiresAt: z8.number().int().positive(),
|
|
516
620
|
// Crypto-random, single-use — consumed atomically by the relay on
|
|
517
621
|
// successful attach (an in-memory, TTL-expiring record; see the relay
|
|
518
622
|
// architecture doc's own correction against an unbounded Set). A
|
|
519
623
|
// replayed ticket (same nonce, still within its own expiry window)
|
|
520
624
|
// fails closed.
|
|
521
|
-
nonce:
|
|
625
|
+
nonce: z8.string().min(1)
|
|
522
626
|
});
|
|
523
|
-
var signedRelayTicketSchema =
|
|
627
|
+
var signedRelayTicketSchema = z8.object({
|
|
524
628
|
ticket: relayTicketSchema,
|
|
525
|
-
signature:
|
|
629
|
+
signature: z8.string().min(1)
|
|
526
630
|
});
|
|
527
631
|
|
|
528
632
|
// ../../packages/local-agent-protocol/src/relay-messages.ts
|
|
529
|
-
import { z as
|
|
530
|
-
var relayFailureReasonSchema =
|
|
633
|
+
import { z as z9 } from "zod";
|
|
634
|
+
var relayFailureReasonSchema = z9.enum([
|
|
531
635
|
// relay.registrationFailed reasons (daemon side)
|
|
532
636
|
"bad_signature",
|
|
533
637
|
"wrong_fingerprint",
|
|
@@ -550,22 +654,22 @@ var relayFailureReasonSchema = z7.enum([
|
|
|
550
654
|
// daemon's own client-side cap (see session-registry.ts's doc comment).
|
|
551
655
|
"session_limit_exceeded"
|
|
552
656
|
]);
|
|
553
|
-
var relayFrameSchema =
|
|
554
|
-
relaySessionId:
|
|
657
|
+
var relayFrameSchema = z9.object({
|
|
658
|
+
relaySessionId: z9.string().min(1),
|
|
555
659
|
// Opaque to the relay — the existing inner BrowserMessage/DaemonMessage
|
|
556
660
|
// JSON, unparsed and unvalidated by anything in this file. Typed as
|
|
557
661
|
// unknown here deliberately; callers narrow it via browserMessageSchema/
|
|
558
662
|
// daemonMessageSchema from messages.ts/schemas.ts after routing.
|
|
559
|
-
payload:
|
|
663
|
+
payload: z9.unknown()
|
|
560
664
|
});
|
|
561
|
-
var relayDaemonMessageSchema =
|
|
562
|
-
|
|
665
|
+
var relayDaemonMessageSchema = z9.discriminatedUnion("type", [
|
|
666
|
+
z9.object({ type: z9.literal("relay.register"), signed: signedRelayRegisterSchema }),
|
|
563
667
|
// Sent in response to relay.sessionOpened, once ConnectionAuthState has
|
|
564
668
|
// actually been allocated — see relay-architecture doc §6's correction:
|
|
565
669
|
// the relay cannot assume this happened just because it sent the
|
|
566
670
|
// message, since bondsmithd is a remote process over a real WSS socket.
|
|
567
|
-
|
|
568
|
-
|
|
671
|
+
z9.object({ type: z9.literal("relay.sessionOpenAck"), relaySessionId: z9.string().min(1) }),
|
|
672
|
+
z9.object({ type: z9.literal("relay.frame"), frame: relayFrameSchema }),
|
|
569
673
|
// Daemon-initiated close — used when BD-434's existing
|
|
570
674
|
// revokeControllerConnections/revokeAllConnections destroys a
|
|
571
675
|
// ConnectionAuthState locally (controller revocation, daemon unpairing)
|
|
@@ -574,51 +678,51 @@ var relayDaemonMessageSchema = z7.discriminatedUnion("type", [
|
|
|
574
678
|
// the time this is sent (revocation's actual authorization guarantee
|
|
575
679
|
// never depends on the relay's cooperation) — this is cleanup, not the
|
|
576
680
|
// authorization boundary.
|
|
577
|
-
|
|
681
|
+
z9.object({ type: z9.literal("relay.closeSession"), relaySessionId: z9.string().min(1) })
|
|
578
682
|
]);
|
|
579
|
-
var relayToDaemonMessageSchema =
|
|
580
|
-
|
|
581
|
-
type:
|
|
582
|
-
challenge:
|
|
583
|
-
expiresAt:
|
|
584
|
-
relayAudience:
|
|
683
|
+
var relayToDaemonMessageSchema = z9.discriminatedUnion("type", [
|
|
684
|
+
z9.object({
|
|
685
|
+
type: z9.literal("relay.challenge"),
|
|
686
|
+
challenge: z9.string().min(1),
|
|
687
|
+
expiresAt: z9.number().int().positive(),
|
|
688
|
+
relayAudience: z9.string().min(1)
|
|
585
689
|
}),
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
690
|
+
z9.object({ type: z9.literal("relay.registered"), daemonFingerprint: z9.string().min(1) }),
|
|
691
|
+
z9.object({ type: z9.literal("relay.registrationFailed"), reason: relayFailureReasonSchema }),
|
|
692
|
+
z9.object({ type: z9.literal("relay.sessionOpened"), relaySessionId: z9.string().min(1) }),
|
|
693
|
+
z9.object({ type: z9.literal("relay.sessionClosed"), relaySessionId: z9.string().min(1) }),
|
|
694
|
+
z9.object({ type: z9.literal("relay.frame"), frame: relayFrameSchema })
|
|
591
695
|
]);
|
|
592
|
-
var relayBrowserMessageSchema =
|
|
593
|
-
|
|
594
|
-
|
|
696
|
+
var relayBrowserMessageSchema = z9.discriminatedUnion("type", [
|
|
697
|
+
z9.object({ type: z9.literal("relay.attach"), signed: signedRelayTicketSchema }),
|
|
698
|
+
z9.object({ type: z9.literal("relay.frame"), frame: relayFrameSchema })
|
|
595
699
|
]);
|
|
596
|
-
var relayToBrowserMessageSchema =
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
700
|
+
var relayToBrowserMessageSchema = z9.discriminatedUnion("type", [
|
|
701
|
+
z9.object({ type: z9.literal("relay.attached"), relaySessionId: z9.string().min(1) }),
|
|
702
|
+
z9.object({ type: z9.literal("relay.attachFailed"), reason: relayFailureReasonSchema }),
|
|
703
|
+
z9.object({ type: z9.literal("relay.daemonOffline") }),
|
|
600
704
|
// Sent when the daemon registration a session was opened against gets
|
|
601
705
|
// superseded (the daemon reconnected) — the session is dead, the browser
|
|
602
706
|
// must perform a fresh relay.attach. "daemon_reconnected" covers V1's
|
|
603
707
|
// single-process supersession case (see relay-architecture.md §4); kept
|
|
604
708
|
// as its own reason (not folded into relay.daemonOffline) since the
|
|
605
709
|
// daemon IS reachable again, just under a new registration.
|
|
606
|
-
|
|
607
|
-
type:
|
|
608
|
-
relaySessionId:
|
|
609
|
-
reason:
|
|
710
|
+
z9.object({
|
|
711
|
+
type: z9.literal("relay.sessionInvalidated"),
|
|
712
|
+
relaySessionId: z9.string().min(1),
|
|
713
|
+
reason: z9.literal("daemon_reconnected")
|
|
610
714
|
}),
|
|
611
|
-
|
|
715
|
+
z9.object({ type: z9.literal("relay.frame"), frame: relayFrameSchema })
|
|
612
716
|
]);
|
|
613
717
|
|
|
614
718
|
// src/index.ts
|
|
615
719
|
import { config as loadEnv } from "dotenv";
|
|
616
720
|
|
|
617
721
|
// src/config/environment.ts
|
|
618
|
-
import { z as
|
|
619
|
-
var
|
|
620
|
-
PORT:
|
|
621
|
-
ALLOWED_ORIGINS:
|
|
722
|
+
import { z as z10 } from "zod";
|
|
723
|
+
var baseEnvironmentSchema = z10.object({
|
|
724
|
+
PORT: z10.coerce.number().int().min(1).max(65535).default(4577),
|
|
725
|
+
ALLOWED_ORIGINS: z10.string().default(
|
|
622
726
|
"http://localhost:3000,http://127.0.0.1:3000,http://localhost:4000,http://127.0.0.1:4000"
|
|
623
727
|
).transform(
|
|
624
728
|
(value) => value.split(",").map((origin) => origin.trim()).filter(Boolean)
|
|
@@ -627,12 +731,14 @@ var environmentSchema = z8.object({
|
|
|
627
731
|
// this token (via the Sec-WebSocket-Protocol header) or be rejected. Unset
|
|
628
732
|
// by default so plain localhost usage is unaffected. Never a substitute for
|
|
629
733
|
// real auth — see apps/bondsmithd/README.md's dev-tunnel section.
|
|
630
|
-
BONDSMITH_DEV_TUNNEL_TOKEN:
|
|
734
|
+
BONDSMITH_DEV_TUNNEL_TOKEN: z10.string().min(1).optional(),
|
|
631
735
|
// Base URL of the Bondsmith backend this daemon pairs itself against
|
|
632
736
|
// (BD-432) — defaults to production so a real install works with zero
|
|
633
737
|
// config; overridden to the local dev server (e.g. http://localhost:4000)
|
|
634
738
|
// when developing bondsmithd itself against a local web app.
|
|
635
|
-
BONDSMITH_API_URL:
|
|
739
|
+
BONDSMITH_API_URL: z10.string().url().default("https://app.bondsmith.dev").refine((value) => isSecureOrLoopbackUrl(value, "https:", "http:"), {
|
|
740
|
+
message: "BONDSMITH_API_URL must use https:// (plain http:// is only allowed for a loopback server such as http://localhost:4000)"
|
|
741
|
+
}),
|
|
636
742
|
// Interface the WebSocket/HTTP server binds to. Defaults to loopback-only
|
|
637
743
|
// (never reachable from outside this machine) — real access control is
|
|
638
744
|
// ALLOWED_ORIGINS + the dev-tunnel token, not the bind address, but
|
|
@@ -641,22 +747,47 @@ var environmentSchema = z8.object({
|
|
|
641
747
|
// that puts something (e.g. Docker's host-gateway route for an ngrok
|
|
642
748
|
// tunnel) between the daemon and the browser — not something a normal
|
|
643
749
|
// install needs to touch.
|
|
644
|
-
BONDSMITH_BIND_HOST:
|
|
750
|
+
BONDSMITH_BIND_HOST: z10.string().min(1).default("127.0.0.1"),
|
|
645
751
|
// BD-450/BD-483/BD-484: connect outbound to the Bondsmith relay instead of
|
|
646
752
|
// (or alongside) listening inbound. ON by default. The existing inbound
|
|
647
753
|
// listener is completely unaffected either way — it keeps running
|
|
648
754
|
// regardless, so direct-connection debugging (bypassing the relay
|
|
649
755
|
// entirely) is still possible by setting BONDSMITH_RELAY_ENABLED=false
|
|
650
756
|
// explicitly.
|
|
651
|
-
BONDSMITH_RELAY_ENABLED:
|
|
757
|
+
BONDSMITH_RELAY_ENABLED: z10.string().default("true").transform((value) => value === "true" || value === "1"),
|
|
652
758
|
// Defaults to production, mirroring BONDSMITH_API_URL above, so a real
|
|
653
759
|
// install works with zero config — a bare `bondsmithd` used to silently
|
|
654
760
|
// connect outbound to loopback (nothing listening there) with no error,
|
|
655
761
|
// looking healthy while never reaching relay at all. Override to a local
|
|
656
762
|
// apps/relay instance (its own `pnpm dev` default port) when developing
|
|
657
763
|
// bondsmithd itself against a local relay.
|
|
658
|
-
BONDSMITH_RELAY_URL:
|
|
764
|
+
BONDSMITH_RELAY_URL: z10.string().url().default("wss://relay.bondsmith.dev"),
|
|
765
|
+
// The audience a non-loopback relay must present. Defaults to the relay URL's host; set it only when the relay's RELAY_AUDIENCE differs from its hostname.
|
|
766
|
+
BONDSMITH_RELAY_AUDIENCE: z10.string().min(1).optional()
|
|
767
|
+
});
|
|
768
|
+
var environmentSchema = baseEnvironmentSchema.superRefine((env, ctx) => {
|
|
769
|
+
if (env.BONDSMITH_RELAY_ENABLED && !isSecureOrLoopbackUrl(env.BONDSMITH_RELAY_URL, "wss:", "ws:")) {
|
|
770
|
+
ctx.addIssue({
|
|
771
|
+
code: z10.ZodIssueCode.custom,
|
|
772
|
+
path: ["BONDSMITH_RELAY_URL"],
|
|
773
|
+
message: "BONDSMITH_RELAY_URL must use wss:// (plain ws:// is only allowed for a loopback relay such as ws://127.0.0.1:8787)"
|
|
774
|
+
});
|
|
775
|
+
}
|
|
659
776
|
});
|
|
777
|
+
var LOOPBACK_HOSTNAMES = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "[::1]"]);
|
|
778
|
+
function isLoopbackUrl(url) {
|
|
779
|
+
return LOOPBACK_HOSTNAMES.has(url.hostname);
|
|
780
|
+
}
|
|
781
|
+
function isSecureOrLoopbackUrl(value, secureProtocol, plainProtocol) {
|
|
782
|
+
if (!URL.canParse(value)) return true;
|
|
783
|
+
const url = new URL(value);
|
|
784
|
+
return url.protocol === secureProtocol || url.protocol === plainProtocol && isLoopbackUrl(url);
|
|
785
|
+
}
|
|
786
|
+
function resolveExpectedRelayAudience(relayUrl, override) {
|
|
787
|
+
if (override) return override;
|
|
788
|
+
const url = new URL(relayUrl);
|
|
789
|
+
return isLoopbackUrl(url) ? null : url.hostname;
|
|
790
|
+
}
|
|
660
791
|
function getDaemonConfig(env = process.env) {
|
|
661
792
|
const parsed = environmentSchema.parse(env);
|
|
662
793
|
return {
|
|
@@ -666,7 +797,11 @@ function getDaemonConfig(env = process.env) {
|
|
|
666
797
|
apiUrl: parsed.BONDSMITH_API_URL,
|
|
667
798
|
bindHost: parsed.BONDSMITH_BIND_HOST,
|
|
668
799
|
relayEnabled: parsed.BONDSMITH_RELAY_ENABLED,
|
|
669
|
-
relayUrl: parsed.BONDSMITH_RELAY_URL
|
|
800
|
+
relayUrl: parsed.BONDSMITH_RELAY_URL,
|
|
801
|
+
expectedRelayAudience: resolveExpectedRelayAudience(
|
|
802
|
+
parsed.BONDSMITH_RELAY_URL,
|
|
803
|
+
parsed.BONDSMITH_RELAY_AUDIENCE
|
|
804
|
+
)
|
|
670
805
|
};
|
|
671
806
|
}
|
|
672
807
|
|
|
@@ -904,6 +1039,7 @@ import { promisify } from "util";
|
|
|
904
1039
|
var execFile = promisify(execFileCb);
|
|
905
1040
|
var MAX_GIT_OUTPUT_BYTES = 64 * 1024 * 1024;
|
|
906
1041
|
var SAFE_DIFF_FLAGS = ["--no-ext-diff", "--no-textconv"];
|
|
1042
|
+
var SAFE_GIT_CONFIG = ["-c", "core.fsmonitor=false"];
|
|
907
1043
|
function splitNulList(output) {
|
|
908
1044
|
return output.split("\0").filter((entry) => entry.length > 0);
|
|
909
1045
|
}
|
|
@@ -920,7 +1056,10 @@ function isNotARepoError(error2) {
|
|
|
920
1056
|
}
|
|
921
1057
|
async function runGit(args, cwd) {
|
|
922
1058
|
try {
|
|
923
|
-
const { stdout } = await execFile("git", args, {
|
|
1059
|
+
const { stdout } = await execFile("git", [...SAFE_GIT_CONFIG, ...args], {
|
|
1060
|
+
cwd,
|
|
1061
|
+
maxBuffer: MAX_GIT_OUTPUT_BYTES
|
|
1062
|
+
});
|
|
924
1063
|
return stdout;
|
|
925
1064
|
} catch (error2) {
|
|
926
1065
|
if (isNotARepoError(error2)) throw new GitNotARepoError();
|
|
@@ -929,7 +1068,10 @@ async function runGit(args, cwd) {
|
|
|
929
1068
|
}
|
|
930
1069
|
async function runGitAllowingExitCodes(args, cwd, allowedExitCodes) {
|
|
931
1070
|
try {
|
|
932
|
-
const { stdout } = await execFile("git", args, {
|
|
1071
|
+
const { stdout } = await execFile("git", [...SAFE_GIT_CONFIG, ...args], {
|
|
1072
|
+
cwd,
|
|
1073
|
+
maxBuffer: MAX_GIT_OUTPUT_BYTES
|
|
1074
|
+
});
|
|
933
1075
|
return stdout;
|
|
934
1076
|
} catch (error2) {
|
|
935
1077
|
if (isNotARepoError(error2)) throw new GitNotARepoError();
|
|
@@ -1005,124 +1147,20 @@ async function validateProjectPath(input) {
|
|
|
1005
1147
|
import { sign } from "crypto";
|
|
1006
1148
|
import WebSocket from "ws";
|
|
1007
1149
|
|
|
1008
|
-
// src/
|
|
1009
|
-
|
|
1010
|
-
var VERSION_FLAGS = ["--version", "-v"];
|
|
1011
|
-
function versionLine() {
|
|
1012
|
-
return `bondsmithd ${DAEMON_VERSION} (protocol ${PROTOCOL_VERSION})`;
|
|
1013
|
-
}
|
|
1014
|
-
function isVersionRequest(argv) {
|
|
1015
|
-
return argv.some((arg) => VERSION_FLAGS.includes(arg));
|
|
1016
|
-
}
|
|
1017
|
-
|
|
1018
|
-
// src/sessions/tmux/client.ts
|
|
1019
|
-
import { execFile as execFileCb2 } from "child_process";
|
|
1020
|
-
import { promisify as promisify2 } from "util";
|
|
1021
|
-
var execFile2 = promisify2(execFileCb2);
|
|
1022
|
-
var TmuxNoServerRunningError = class extends Error {
|
|
1023
|
-
constructor() {
|
|
1024
|
-
super("no tmux server running");
|
|
1025
|
-
this.name = "TmuxNoServerRunningError";
|
|
1026
|
-
}
|
|
1027
|
-
};
|
|
1028
|
-
var TmuxNotFoundError = class extends Error {
|
|
1029
|
-
constructor() {
|
|
1030
|
-
super(
|
|
1031
|
-
[
|
|
1032
|
-
"bondsmithd requires tmux, and it was not found on PATH.",
|
|
1033
|
-
"",
|
|
1034
|
-
"Install it:",
|
|
1035
|
-
" macOS (Homebrew): brew install tmux",
|
|
1036
|
-
" Debian/Ubuntu: sudo apt-get install tmux",
|
|
1037
|
-
" Fedora/RHEL: sudo dnf install tmux",
|
|
1038
|
-
" Arch: sudo pacman -S tmux",
|
|
1039
|
-
"",
|
|
1040
|
-
"If tmux is already installed, start bondsmithd from a normal interactive shell \u2014",
|
|
1041
|
-
"not a LaunchAgent, Docker container, or other minimal-PATH context. Those often",
|
|
1042
|
-
"don't inherit the PATH tmux was installed into (e.g. Homebrew's /opt/homebrew/bin",
|
|
1043
|
-
"on Apple Silicon)."
|
|
1044
|
-
].join("\n")
|
|
1045
|
-
);
|
|
1046
|
-
this.name = "TmuxNotFoundError";
|
|
1047
|
-
}
|
|
1048
|
-
};
|
|
1049
|
-
var SENSITIVE_ENV_KEYS = ["ANTHROPIC_API_KEY", "BONDSMITH_DEV_TUNNEL_TOKEN"];
|
|
1050
|
-
function filterSpawnEnv(env) {
|
|
1051
|
-
const filtered = { ...env };
|
|
1052
|
-
for (const key of SENSITIVE_ENV_KEYS) delete filtered[key];
|
|
1053
|
-
return filtered;
|
|
1054
|
-
}
|
|
1055
|
-
async function runTmux(args, options) {
|
|
1056
|
-
try {
|
|
1057
|
-
const { stdout } = await execFile2("tmux", args, { ...options, encoding: "utf8" });
|
|
1058
|
-
return stdout;
|
|
1059
|
-
} catch (error2) {
|
|
1060
|
-
if (error2?.code === "ENOENT") throw new TmuxNotFoundError();
|
|
1061
|
-
throw error2;
|
|
1062
|
-
}
|
|
1063
|
-
}
|
|
1064
|
-
async function sendKeysLiteral(tmuxSessionName, text) {
|
|
1065
|
-
await runTmux(["send-keys", "-t", tmuxSessionName, "-l", text]);
|
|
1066
|
-
}
|
|
1067
|
-
async function capturePane(tmuxSessionName) {
|
|
1068
|
-
return runTmux(["capture-pane", "-t", tmuxSessionName, "-p", "-e"]);
|
|
1069
|
-
}
|
|
1070
|
-
async function getSessionActivity(tmuxSessionName) {
|
|
1071
|
-
try {
|
|
1072
|
-
const out = (await runTmux(["display-message", "-p", "-t", tmuxSessionName, "#{window_activity}"])).trim();
|
|
1073
|
-
const seconds = Number(out);
|
|
1074
|
-
if (!out || Number.isNaN(seconds)) return null;
|
|
1075
|
-
return new Date(seconds * 1e3).toISOString();
|
|
1076
|
-
} catch {
|
|
1077
|
-
return null;
|
|
1078
|
-
}
|
|
1079
|
-
}
|
|
1080
|
-
var PLAN_OPTION = "@bondsmith-plan";
|
|
1081
|
-
async function getSessionPlan(tmuxSessionName) {
|
|
1082
|
-
try {
|
|
1083
|
-
const plan = (await runTmux(["show-options", "-qv", "-t", tmuxSessionName, PLAN_OPTION])).trim();
|
|
1084
|
-
return plan ? plan.slice(0, MAX_PLAN_CHARS) : null;
|
|
1085
|
-
} catch (error2) {
|
|
1086
|
-
if (error2 instanceof TmuxNotFoundError) throw error2;
|
|
1087
|
-
return null;
|
|
1088
|
-
}
|
|
1089
|
-
}
|
|
1090
|
-
async function listTmuxSessions(formatString) {
|
|
1091
|
-
try {
|
|
1092
|
-
return await runTmux(["list-sessions", "-F", formatString]);
|
|
1093
|
-
} catch (error2) {
|
|
1094
|
-
if (isNoServerError(error2)) throw new TmuxNoServerRunningError();
|
|
1095
|
-
throw error2;
|
|
1096
|
-
}
|
|
1097
|
-
}
|
|
1098
|
-
var NO_SERVER_MESSAGE_PATTERNS = [
|
|
1099
|
-
/no server running/i,
|
|
1100
|
-
/no current session/i,
|
|
1101
|
-
/no sessions/i,
|
|
1102
|
-
/failed to connect to server/i,
|
|
1103
|
-
/error connecting to .*\((no such file or directory|connection refused)\)/i
|
|
1104
|
-
];
|
|
1105
|
-
function isNoServerError(error2) {
|
|
1106
|
-
if (typeof error2 !== "object" || error2 === null) return false;
|
|
1107
|
-
const { message, stderr } = error2;
|
|
1108
|
-
const text = [message, stderr].filter((part) => typeof part === "string").join("\n");
|
|
1109
|
-
return NO_SERVER_MESSAGE_PATTERNS.some((pattern) => pattern.test(text));
|
|
1110
|
-
}
|
|
1111
|
-
|
|
1112
|
-
// src/security/verify-command.ts
|
|
1113
|
-
import { verify as verify2 } from "crypto";
|
|
1150
|
+
// src/security/connection-auth.ts
|
|
1151
|
+
import { randomBytes, verify as verify2 } from "crypto";
|
|
1114
1152
|
|
|
1115
1153
|
// src/security/trusted-controllers.ts
|
|
1116
1154
|
import { mkdir, readFile, rename, rm, writeFile } from "fs/promises";
|
|
1117
1155
|
import { homedir as homedir2 } from "os";
|
|
1118
1156
|
import { dirname, join as join2 } from "path";
|
|
1119
|
-
import { z as
|
|
1120
|
-
var trustedControllerSchema =
|
|
1121
|
-
id:
|
|
1122
|
-
publicKeyPem:
|
|
1123
|
-
label:
|
|
1124
|
-
addedAt:
|
|
1125
|
-
lastUsedAt:
|
|
1157
|
+
import { z as z11 } from "zod";
|
|
1158
|
+
var trustedControllerSchema = z11.object({
|
|
1159
|
+
id: z11.string(),
|
|
1160
|
+
publicKeyPem: z11.string(),
|
|
1161
|
+
label: z11.string(),
|
|
1162
|
+
addedAt: z11.string(),
|
|
1163
|
+
lastUsedAt: z11.string().nullable(),
|
|
1126
1164
|
// Obsolete as of ADR-013/BD-477 — replay protection moved to an
|
|
1127
1165
|
// ephemeral, connection-scoped sequence (connection-auth.ts), never
|
|
1128
1166
|
// persisted. Accepted-but-ignored here (`.optional()`, not required, and
|
|
@@ -1131,11 +1169,11 @@ var trustedControllerSchema = z9.object({
|
|
|
1131
1169
|
// before this change still parses correctly — controller trust and
|
|
1132
1170
|
// revocation state is never lost — instead of throwing on an "unknown"
|
|
1133
1171
|
// shape. Never written by this file's own persist() calls going forward.
|
|
1134
|
-
lastSeenSequence:
|
|
1172
|
+
lastSeenSequence: z11.number().int().nonnegative().optional()
|
|
1135
1173
|
});
|
|
1136
|
-
var trustedControllersFileSchema =
|
|
1137
|
-
version:
|
|
1138
|
-
controllers:
|
|
1174
|
+
var trustedControllersFileSchema = z11.object({
|
|
1175
|
+
version: z11.literal(1),
|
|
1176
|
+
controllers: z11.array(trustedControllerSchema)
|
|
1139
1177
|
});
|
|
1140
1178
|
function defaultTrustedControllersPath() {
|
|
1141
1179
|
return join2(homedir2(), ".bondsmith", "trusted-controllers.json");
|
|
@@ -1202,32 +1240,363 @@ async function recordControllerHandshake(controllerId, path = defaultTrustedCont
|
|
|
1202
1240
|
await persist(path, next);
|
|
1203
1241
|
});
|
|
1204
1242
|
}
|
|
1205
|
-
var trustFileLocks = /* @__PURE__ */ new Map();
|
|
1206
|
-
function withTrustFileLock(path, fn) {
|
|
1207
|
-
const previous = trustFileLocks.get(path) ?? Promise.resolve();
|
|
1208
|
-
const next = previous.catch(() => {
|
|
1209
|
-
}).then(fn);
|
|
1210
|
-
trustFileLocks.set(
|
|
1211
|
-
path,
|
|
1212
|
-
next.catch(() => {
|
|
1213
|
-
})
|
|
1214
|
-
);
|
|
1215
|
-
return next;
|
|
1243
|
+
var trustFileLocks = /* @__PURE__ */ new Map();
|
|
1244
|
+
function withTrustFileLock(path, fn) {
|
|
1245
|
+
const previous = trustFileLocks.get(path) ?? Promise.resolve();
|
|
1246
|
+
const next = previous.catch(() => {
|
|
1247
|
+
}).then(fn);
|
|
1248
|
+
trustFileLocks.set(
|
|
1249
|
+
path,
|
|
1250
|
+
next.catch(() => {
|
|
1251
|
+
})
|
|
1252
|
+
);
|
|
1253
|
+
return next;
|
|
1254
|
+
}
|
|
1255
|
+
async function persist(path, controllers) {
|
|
1256
|
+
await mkdir(dirname(path), { recursive: true });
|
|
1257
|
+
const tmpPath = `${path}.tmp-${process.pid}-${Date.now()}`;
|
|
1258
|
+
const contents = JSON.stringify({ version: 1, controllers }, null, 2);
|
|
1259
|
+
try {
|
|
1260
|
+
await writeFile(tmpPath, contents, { mode: 384 });
|
|
1261
|
+
await rename(tmpPath, path);
|
|
1262
|
+
} catch (error2) {
|
|
1263
|
+
await rm(tmpPath, { force: true });
|
|
1264
|
+
throw error2;
|
|
1265
|
+
}
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
// src/security/connection-auth.ts
|
|
1269
|
+
var CHALLENGE_LENGTH_BYTES = 32;
|
|
1270
|
+
var CONNECTION_ID_LENGTH_BYTES = 24;
|
|
1271
|
+
var CHALLENGE_LIFETIME_MS = 3e4;
|
|
1272
|
+
var UNAUTHENTICATED_CONNECTION_TIMEOUT_MS = CHALLENGE_LIFETIME_MS;
|
|
1273
|
+
var MAX_FAILED_HANDSHAKES_PER_CONNECTION = 3;
|
|
1274
|
+
var MAX_FAILED_HANDSHAKES_PER_WINDOW = 300;
|
|
1275
|
+
var HANDSHAKE_RATE_LIMIT_WINDOW_MS = 6e4;
|
|
1276
|
+
var failedHandshakeTimestamps = [];
|
|
1277
|
+
function isHandshakeRateLimited() {
|
|
1278
|
+
const windowStart = Date.now() - HANDSHAKE_RATE_LIMIT_WINDOW_MS;
|
|
1279
|
+
failedHandshakeTimestamps = failedHandshakeTimestamps.filter((t) => t > windowStart);
|
|
1280
|
+
return failedHandshakeTimestamps.length >= MAX_FAILED_HANDSHAKES_PER_WINDOW;
|
|
1281
|
+
}
|
|
1282
|
+
function recordFailedHandshake() {
|
|
1283
|
+
failedHandshakeTimestamps.push(Date.now());
|
|
1284
|
+
}
|
|
1285
|
+
var ConnectionAuthState = class {
|
|
1286
|
+
constructor(daemonFingerprint) {
|
|
1287
|
+
this.daemonFingerprint = daemonFingerprint;
|
|
1288
|
+
this.connectionId = randomBytes(CONNECTION_ID_LENGTH_BYTES).toString("base64url");
|
|
1289
|
+
this.challenge = randomBytes(CHALLENGE_LENGTH_BYTES).toString("base64url");
|
|
1290
|
+
this.challengeExpiresAt = Date.now() + CHALLENGE_LIFETIME_MS;
|
|
1291
|
+
}
|
|
1292
|
+
connectionId;
|
|
1293
|
+
challenge;
|
|
1294
|
+
challengeExpiresAt;
|
|
1295
|
+
authenticated = null;
|
|
1296
|
+
lastSequence = 0;
|
|
1297
|
+
connectionKey = null;
|
|
1298
|
+
failedHandshakes = 0;
|
|
1299
|
+
// BD-487: set synchronously by revocation (server.ts's
|
|
1300
|
+
// revokeControllerConnections/revokeAllConnections, or relay-client.ts's
|
|
1301
|
+
// identically-named functions), checked synchronously again in
|
|
1302
|
+
// router.ts's handleBrowserMessage immediately before dispatch() runs —
|
|
1303
|
+
// closes the gap where a command already past verifyCommand's async
|
|
1304
|
+
// awaits (getTrustedController's disk read, checkAndAdvanceSequence)
|
|
1305
|
+
// would otherwise still execute even though revocation was supposed to
|
|
1306
|
+
// have already ended this connection's authority. Deliberately a plain
|
|
1307
|
+
// synchronous flag, not itself awaited anywhere: the race this closes is
|
|
1308
|
+
// specifically the gap between an async check's awaits, so the fix must
|
|
1309
|
+
// be a check with zero await distance from dispatch(), not another
|
|
1310
|
+
// async lookup that reopens the same kind of window.
|
|
1311
|
+
revoked = false;
|
|
1312
|
+
// Serializes sequence verify-and-advance within this one connection —
|
|
1313
|
+
// without this, two commands signed close together could have their
|
|
1314
|
+
// async verification interleave and both read the same "last sequence"
|
|
1315
|
+
// before either advances it, letting a lower/equal sequence slip through
|
|
1316
|
+
// as if it were the next one. A per-connection promise chain (same
|
|
1317
|
+
// pattern as trusted-controllers.ts's withTrustFileLock, but in-memory
|
|
1318
|
+
// and scoped to one socket instead of a file path) is sufficient — never
|
|
1319
|
+
// contends across connections, and there is no file I/O to race.
|
|
1320
|
+
sequenceChain = Promise.resolve();
|
|
1321
|
+
// Serializes authenticate() the same way sequenceChain serializes
|
|
1322
|
+
// checkAndAdvanceSequence — adversarial review (BD-477 follow-up) found
|
|
1323
|
+
// that two connection.authenticate frames arriving back-to-back on one
|
|
1324
|
+
// socket could both pass the `this.authenticated === null` check before
|
|
1325
|
+
// either reached the assignment at the end of the async verification
|
|
1326
|
+
// chain, since getTrustedController is a real await gap. Both attempts
|
|
1327
|
+
// still require a validly-signed transcript (this was never an auth
|
|
1328
|
+
// bypass), but the second could silently overwrite the first's identity
|
|
1329
|
+
// and double-register the socket in server.ts's connectionsByController
|
|
1330
|
+
// map. Without a chain here, the fix would need a synchronous
|
|
1331
|
+
// compare-and-set instead, which would need to happen before the
|
|
1332
|
+
// signature is even checked — this chain is simpler and matches the
|
|
1333
|
+
// sequence check's own established pattern.
|
|
1334
|
+
authenticateChain = Promise.resolve();
|
|
1335
|
+
getChallenge() {
|
|
1336
|
+
return {
|
|
1337
|
+
connectionId: this.connectionId,
|
|
1338
|
+
challenge: this.challenge,
|
|
1339
|
+
expiresAt: this.challengeExpiresAt
|
|
1340
|
+
};
|
|
1341
|
+
}
|
|
1342
|
+
isAuthenticated() {
|
|
1343
|
+
return this.authenticated !== null;
|
|
1344
|
+
}
|
|
1345
|
+
/** The key derived from this connection's handshake, held in memory only; null when the browser offered no key. */
|
|
1346
|
+
setConnectionKey(key) {
|
|
1347
|
+
this.connectionKey = key;
|
|
1348
|
+
}
|
|
1349
|
+
getConnectionKey() {
|
|
1350
|
+
return this.connectionKey;
|
|
1351
|
+
}
|
|
1352
|
+
/** Ends this connection's authority and marks it for closing, since the transports close a connection once its handshake attempts are exhausted. */
|
|
1353
|
+
abandonAfterKeyAgreementFailure() {
|
|
1354
|
+
this.revoked = true;
|
|
1355
|
+
this.failedHandshakes = MAX_FAILED_HANDSHAKES_PER_CONNECTION;
|
|
1356
|
+
}
|
|
1357
|
+
/** BD-487: marks this connection's authority as ended — called by revocation, checked by router.ts immediately before dispatch(). Idempotent; a connection is never un-revoked. */
|
|
1358
|
+
revoke() {
|
|
1359
|
+
this.revoked = true;
|
|
1360
|
+
}
|
|
1361
|
+
isRevoked() {
|
|
1362
|
+
return this.revoked;
|
|
1363
|
+
}
|
|
1364
|
+
/** Unsolicited daemon broadcasts (session.created/updated) go only to connections that completed the handshake and still hold authority. */
|
|
1365
|
+
canReceiveBroadcasts() {
|
|
1366
|
+
return this.authenticated !== null && !this.revoked;
|
|
1367
|
+
}
|
|
1368
|
+
/** The controller/account this connection authenticated as, or null before a successful handshake. Used by verify-command.ts to reject an envelope signed by a DIFFERENT controller than the one that authenticated this connection. */
|
|
1369
|
+
getAuthenticatedIdentity() {
|
|
1370
|
+
return this.authenticated;
|
|
1371
|
+
}
|
|
1372
|
+
/**
|
|
1373
|
+
* Verifies a signed connection-handshake transcript and, on success,
|
|
1374
|
+
* marks this connection authenticated — exactly once, including under
|
|
1375
|
+
* concurrent calls. A call after a SUCCESSFUL handshake (even one that
|
|
1376
|
+
* overlaps in time) is rejected with "already_authenticated"; a failed
|
|
1377
|
+
* attempt does not consume the slot, up to this connection's failure cap.
|
|
1378
|
+
* The challenge is bound to this ConnectionAuthState instance and is never
|
|
1379
|
+
* reissued or extended, so a duplicate, delayed, or concurrent attempt can
|
|
1380
|
+
* never re-authenticate or reset this connection's sequence counter.
|
|
1381
|
+
*
|
|
1382
|
+
* Serialized via authenticateChain (same pattern as
|
|
1383
|
+
* checkAndAdvanceSequence's sequenceChain) so two connection.authenticate
|
|
1384
|
+
* frames arriving close together on one socket can't both observe
|
|
1385
|
+
* `this.authenticated === null` before either finishes — without this,
|
|
1386
|
+
* both could pass the guard, both complete real signature verification
|
|
1387
|
+
* (this was never an auth bypass on its own), and the second's
|
|
1388
|
+
* assignment would silently overwrite the first's, double-registering
|
|
1389
|
+
* the socket under two controller identities.
|
|
1390
|
+
*
|
|
1391
|
+
* Checks run in a fixed order regardless of which field is
|
|
1392
|
+
* attacker-controlled, matching verify-command.ts's own discipline:
|
|
1393
|
+
* connection binding and freshness are checked before the signature is
|
|
1394
|
+
* even verified (they're cheap, local, and don't leak anything about
|
|
1395
|
+
* controller trust), then the signature, then controller trust —
|
|
1396
|
+
* signature must pass before ANY other field of the transcript is
|
|
1397
|
+
* trusted for a decision, since those fields are attacker-controlled
|
|
1398
|
+
* until the signature over the whole transcript is checked.
|
|
1399
|
+
*/
|
|
1400
|
+
authenticate(signed, trustedControllersPath) {
|
|
1401
|
+
const task = this.authenticateChain.then(
|
|
1402
|
+
() => this.authenticateOnce(signed, trustedControllersPath)
|
|
1403
|
+
);
|
|
1404
|
+
this.authenticateChain = task.catch(() => {
|
|
1405
|
+
});
|
|
1406
|
+
return task;
|
|
1407
|
+
}
|
|
1408
|
+
async authenticateOnce(signed, trustedControllersPath) {
|
|
1409
|
+
if (this.authenticated) {
|
|
1410
|
+
return { ok: false, reason: "already_authenticated" };
|
|
1411
|
+
}
|
|
1412
|
+
if (isHandshakeRateLimited()) {
|
|
1413
|
+
this.failedHandshakes += 1;
|
|
1414
|
+
return { ok: false, reason: "rate_limited" };
|
|
1415
|
+
}
|
|
1416
|
+
const result = await this.verifyHandshake(signed, trustedControllersPath);
|
|
1417
|
+
if (!result.ok) {
|
|
1418
|
+
this.failedHandshakes += 1;
|
|
1419
|
+
recordFailedHandshake();
|
|
1420
|
+
}
|
|
1421
|
+
return result;
|
|
1422
|
+
}
|
|
1423
|
+
/** True once this connection has used up its failed handshakes; its transport must close it. */
|
|
1424
|
+
hasExhaustedHandshakeAttempts() {
|
|
1425
|
+
return this.failedHandshakes >= MAX_FAILED_HANDSHAKES_PER_CONNECTION;
|
|
1426
|
+
}
|
|
1427
|
+
async verifyHandshake(signed, trustedControllersPath) {
|
|
1428
|
+
const { transcript, signature } = signed;
|
|
1429
|
+
if (transcript.purpose !== CONNECTION_CHALLENGE_PURPOSE) {
|
|
1430
|
+
return { ok: false, reason: "bad_signature" };
|
|
1431
|
+
}
|
|
1432
|
+
if (transcript.connectionId !== this.connectionId || transcript.challenge !== this.challenge) {
|
|
1433
|
+
return { ok: false, reason: "wrong_connection" };
|
|
1434
|
+
}
|
|
1435
|
+
if (Date.now() >= this.challengeExpiresAt) {
|
|
1436
|
+
return { ok: false, reason: "challenge_expired" };
|
|
1437
|
+
}
|
|
1438
|
+
if (transcript.daemonFingerprint !== this.daemonFingerprint) {
|
|
1439
|
+
return { ok: false, reason: "wrong_daemon" };
|
|
1440
|
+
}
|
|
1441
|
+
const controller = await getTrustedController(transcript.controllerId, trustedControllersPath);
|
|
1442
|
+
if (!controller) {
|
|
1443
|
+
return { ok: false, reason: "untrusted_controller" };
|
|
1444
|
+
}
|
|
1445
|
+
let signatureValid;
|
|
1446
|
+
try {
|
|
1447
|
+
signatureValid = verify2(
|
|
1448
|
+
"sha256",
|
|
1449
|
+
Buffer.from(canonicalize(transcript), "utf8"),
|
|
1450
|
+
// WebCrypto's ECDSA signatures are raw r||s (IEEE P1363), not the
|
|
1451
|
+
// DER encoding node:crypto defaults to for EC keys — matches
|
|
1452
|
+
// verify-command.ts's and pairing.ts's identical handling.
|
|
1453
|
+
{ key: controller.publicKeyPem, dsaEncoding: "ieee-p1363" },
|
|
1454
|
+
Buffer.from(signature, "base64")
|
|
1455
|
+
);
|
|
1456
|
+
} catch {
|
|
1457
|
+
signatureValid = false;
|
|
1458
|
+
}
|
|
1459
|
+
if (!signatureValid) {
|
|
1460
|
+
return { ok: false, reason: "bad_signature" };
|
|
1461
|
+
}
|
|
1462
|
+
this.authenticated = { controllerId: transcript.controllerId, accountId: transcript.accountId };
|
|
1463
|
+
recordControllerHandshake(transcript.controllerId, trustedControllersPath).catch(
|
|
1464
|
+
(err) => {
|
|
1465
|
+
console.error("Failed to record controller handshake (non-fatal)", err);
|
|
1466
|
+
}
|
|
1467
|
+
);
|
|
1468
|
+
return { ok: true, controllerId: transcript.controllerId, accountId: transcript.accountId };
|
|
1469
|
+
}
|
|
1470
|
+
/**
|
|
1471
|
+
* Verifies a command envelope's sequence number is strictly greater than
|
|
1472
|
+
* the last one accepted on THIS connection, then advances the
|
|
1473
|
+
* counter — serialized via sequenceChain so two verify calls racing
|
|
1474
|
+
* close together can never both read the same lastSequence before
|
|
1475
|
+
* either advances it. Must only be called for an envelope whose
|
|
1476
|
+
* connectionId has already been confirmed to match this connection
|
|
1477
|
+
* (router.ts checks that before calling this) and whose controllerId
|
|
1478
|
+
* matches this connection's authenticated controller (same caller
|
|
1479
|
+
* responsibility) — this method only ever checks the sequence number
|
|
1480
|
+
* itself, not connection/controller binding, which is the caller's job.
|
|
1481
|
+
*/
|
|
1482
|
+
async checkAndAdvanceSequence(sequence) {
|
|
1483
|
+
if (!this.authenticated) {
|
|
1484
|
+
return { ok: false, reason: "not_authenticated" };
|
|
1485
|
+
}
|
|
1486
|
+
const task = this.sequenceChain.then(() => {
|
|
1487
|
+
if (sequence <= this.lastSequence) {
|
|
1488
|
+
return { ok: false, reason: "replayed" };
|
|
1489
|
+
}
|
|
1490
|
+
this.lastSequence = sequence;
|
|
1491
|
+
return { ok: true };
|
|
1492
|
+
});
|
|
1493
|
+
this.sequenceChain = task.catch(() => {
|
|
1494
|
+
});
|
|
1495
|
+
return task;
|
|
1496
|
+
}
|
|
1497
|
+
};
|
|
1498
|
+
|
|
1499
|
+
// src/version.ts
|
|
1500
|
+
var DAEMON_VERSION = true ? "0.1.11" : "dev";
|
|
1501
|
+
var VERSION_FLAGS = ["--version", "-v"];
|
|
1502
|
+
function versionLine() {
|
|
1503
|
+
return `bondsmithd ${DAEMON_VERSION} (protocol ${PROTOCOL_VERSION})`;
|
|
1504
|
+
}
|
|
1505
|
+
function isVersionRequest(argv) {
|
|
1506
|
+
return argv.some((arg) => VERSION_FLAGS.includes(arg));
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
// src/sessions/tmux/client.ts
|
|
1510
|
+
import { execFile as execFileCb2 } from "child_process";
|
|
1511
|
+
import { promisify as promisify2 } from "util";
|
|
1512
|
+
var execFile2 = promisify2(execFileCb2);
|
|
1513
|
+
var TmuxNoServerRunningError = class extends Error {
|
|
1514
|
+
constructor() {
|
|
1515
|
+
super("no tmux server running");
|
|
1516
|
+
this.name = "TmuxNoServerRunningError";
|
|
1517
|
+
}
|
|
1518
|
+
};
|
|
1519
|
+
var TmuxNotFoundError = class extends Error {
|
|
1520
|
+
constructor() {
|
|
1521
|
+
super(
|
|
1522
|
+
[
|
|
1523
|
+
"bondsmithd requires tmux, and it was not found on PATH.",
|
|
1524
|
+
"",
|
|
1525
|
+
"Install it:",
|
|
1526
|
+
" macOS (Homebrew): brew install tmux",
|
|
1527
|
+
" Debian/Ubuntu: sudo apt-get install tmux",
|
|
1528
|
+
" Fedora/RHEL: sudo dnf install tmux",
|
|
1529
|
+
" Arch: sudo pacman -S tmux",
|
|
1530
|
+
"",
|
|
1531
|
+
"If tmux is already installed, start bondsmithd from a normal interactive shell \u2014",
|
|
1532
|
+
"not a LaunchAgent, Docker container, or other minimal-PATH context. Those often",
|
|
1533
|
+
"don't inherit the PATH tmux was installed into (e.g. Homebrew's /opt/homebrew/bin",
|
|
1534
|
+
"on Apple Silicon)."
|
|
1535
|
+
].join("\n")
|
|
1536
|
+
);
|
|
1537
|
+
this.name = "TmuxNotFoundError";
|
|
1538
|
+
}
|
|
1539
|
+
};
|
|
1540
|
+
var SENSITIVE_ENV_KEYS = ["ANTHROPIC_API_KEY", "BONDSMITH_DEV_TUNNEL_TOKEN"];
|
|
1541
|
+
function filterSpawnEnv(env) {
|
|
1542
|
+
const filtered = { ...env };
|
|
1543
|
+
for (const key of SENSITIVE_ENV_KEYS) delete filtered[key];
|
|
1544
|
+
return filtered;
|
|
1545
|
+
}
|
|
1546
|
+
async function runTmux(args, options) {
|
|
1547
|
+
try {
|
|
1548
|
+
const { stdout } = await execFile2("tmux", args, { ...options, encoding: "utf8" });
|
|
1549
|
+
return stdout;
|
|
1550
|
+
} catch (error2) {
|
|
1551
|
+
if (error2?.code === "ENOENT") throw new TmuxNotFoundError();
|
|
1552
|
+
throw error2;
|
|
1553
|
+
}
|
|
1554
|
+
}
|
|
1555
|
+
async function sendKeysLiteral(tmuxSessionName, text) {
|
|
1556
|
+
await runTmux(["send-keys", "-t", tmuxSessionName, "-l", text]);
|
|
1557
|
+
}
|
|
1558
|
+
async function capturePane(tmuxSessionName) {
|
|
1559
|
+
return runTmux(["capture-pane", "-t", tmuxSessionName, "-p", "-e"]);
|
|
1560
|
+
}
|
|
1561
|
+
async function getSessionActivity(tmuxSessionName) {
|
|
1562
|
+
try {
|
|
1563
|
+
const out = (await runTmux(["display-message", "-p", "-t", tmuxSessionName, "#{window_activity}"])).trim();
|
|
1564
|
+
const seconds = Number(out);
|
|
1565
|
+
if (!out || Number.isNaN(seconds)) return null;
|
|
1566
|
+
return new Date(seconds * 1e3).toISOString();
|
|
1567
|
+
} catch {
|
|
1568
|
+
return null;
|
|
1569
|
+
}
|
|
1570
|
+
}
|
|
1571
|
+
var PLAN_OPTION = "@bondsmith-plan";
|
|
1572
|
+
async function getSessionPlan(tmuxSessionName) {
|
|
1573
|
+
const plan = (await runTmux(["show-options", "-qv", "-t", tmuxSessionName, PLAN_OPTION])).trim();
|
|
1574
|
+
return plan ? plan.slice(0, MAX_PLAN_CHARS) : null;
|
|
1216
1575
|
}
|
|
1217
|
-
async function
|
|
1218
|
-
await mkdir(dirname(path), { recursive: true });
|
|
1219
|
-
const tmpPath = `${path}.tmp-${process.pid}-${Date.now()}`;
|
|
1220
|
-
const contents = JSON.stringify({ version: 1, controllers }, null, 2);
|
|
1576
|
+
async function listTmuxSessions(formatString) {
|
|
1221
1577
|
try {
|
|
1222
|
-
await
|
|
1223
|
-
await rename(tmpPath, path);
|
|
1578
|
+
return await runTmux(["list-sessions", "-F", formatString]);
|
|
1224
1579
|
} catch (error2) {
|
|
1225
|
-
|
|
1580
|
+
if (isNoServerError(error2)) throw new TmuxNoServerRunningError();
|
|
1226
1581
|
throw error2;
|
|
1227
1582
|
}
|
|
1228
1583
|
}
|
|
1584
|
+
var NO_SERVER_MESSAGE_PATTERNS = [
|
|
1585
|
+
/no server running/i,
|
|
1586
|
+
/no current session/i,
|
|
1587
|
+
/no sessions/i,
|
|
1588
|
+
/failed to connect to server/i,
|
|
1589
|
+
/error connecting to .*\((no such file or directory|connection refused)\)/i
|
|
1590
|
+
];
|
|
1591
|
+
function isNoServerError(error2) {
|
|
1592
|
+
if (typeof error2 !== "object" || error2 === null) return false;
|
|
1593
|
+
const { message, stderr } = error2;
|
|
1594
|
+
const text = [message, stderr].filter((part) => typeof part === "string").join("\n");
|
|
1595
|
+
return NO_SERVER_MESSAGE_PATTERNS.some((pattern) => pattern.test(text));
|
|
1596
|
+
}
|
|
1229
1597
|
|
|
1230
1598
|
// src/security/verify-command.ts
|
|
1599
|
+
import { verify as verify3 } from "crypto";
|
|
1231
1600
|
var ENVELOPE_MAX_AGE_MS = 6e4;
|
|
1232
1601
|
var CLOCK_SKEW_TOLERANCE_MS = 5e3;
|
|
1233
1602
|
async function verifyCommand(command, daemonFingerprint, connectionState, trustedControllersPath) {
|
|
@@ -1249,7 +1618,7 @@ async function verifyCommand(command, daemonFingerprint, connectionState, truste
|
|
|
1249
1618
|
const canonicalBytes = Buffer.from(canonicalize(envelope), "utf8");
|
|
1250
1619
|
let signatureValid;
|
|
1251
1620
|
try {
|
|
1252
|
-
signatureValid =
|
|
1621
|
+
signatureValid = verify3(
|
|
1253
1622
|
"sha256",
|
|
1254
1623
|
canonicalBytes,
|
|
1255
1624
|
// WebCrypto's ECDSA signatures are raw r||s (IEEE P1363), not the
|
|
@@ -1357,6 +1726,14 @@ function createOutputPacer(options) {
|
|
|
1357
1726
|
};
|
|
1358
1727
|
}
|
|
1359
1728
|
|
|
1729
|
+
// src/ws/sanitize-insert-text.ts
|
|
1730
|
+
var DISALLOWED_CHARACTERS = /[\x00-\x08\x0b-\x1f\x7f-\x9f--]/g;
|
|
1731
|
+
function sanitizeInsertText(text) {
|
|
1732
|
+
const withLineFeeds = text.replace(/\r\n?/g, "\n");
|
|
1733
|
+
const sanitized = withLineFeeds.replace(DISALLOWED_CHARACTERS, "");
|
|
1734
|
+
return { text: sanitized, strippedCount: withLineFeeds.length - sanitized.length };
|
|
1735
|
+
}
|
|
1736
|
+
|
|
1360
1737
|
// src/ws/router.ts
|
|
1361
1738
|
var SIGNABLE_ACTION_SET = new Set(SIGNABLE_ACTIONS);
|
|
1362
1739
|
function isSignableAction(type) {
|
|
@@ -1526,7 +1903,30 @@ async function handleBrowserMessage(conn, raw, deps) {
|
|
|
1526
1903
|
return;
|
|
1527
1904
|
}
|
|
1528
1905
|
conn.markControllerId(result.controllerId);
|
|
1529
|
-
|
|
1906
|
+
let daemonEcdhPublicKey;
|
|
1907
|
+
if (signed.transcript.clientEcdhPublicKey) {
|
|
1908
|
+
try {
|
|
1909
|
+
const exchange = await createEphemeralKeyExchange("daemon");
|
|
1910
|
+
daemonEcdhPublicKey = exchange.publicKey;
|
|
1911
|
+
const keyedTranscript = buildDaemonProofTranscript(signed.transcript, daemonEcdhPublicKey);
|
|
1912
|
+
if (!keyedTranscript) throw new Error("A key agreement needs a clientNonce");
|
|
1913
|
+
conn.connectionAuth.setConnectionKey(
|
|
1914
|
+
await exchange.deriveKey(signed.transcript.clientEcdhPublicKey, keyedTranscript)
|
|
1915
|
+
);
|
|
1916
|
+
} catch (err) {
|
|
1917
|
+
console.error("Key agreement failed; ending the connection", err);
|
|
1918
|
+
conn.connectionAuth.abandonAfterKeyAgreementFailure();
|
|
1919
|
+
conn.send({ type: "connection.authenticationFailed", reason: "key_agreement_failed" });
|
|
1920
|
+
return;
|
|
1921
|
+
}
|
|
1922
|
+
}
|
|
1923
|
+
const proofTranscript = buildDaemonProofTranscript(signed.transcript, daemonEcdhPublicKey);
|
|
1924
|
+
conn.send({
|
|
1925
|
+
type: "connection.authenticated",
|
|
1926
|
+
connectionId: conn.connectionAuth.connectionId,
|
|
1927
|
+
...proofTranscript && { daemonSignature: deps.signDaemonProof(proofTranscript) },
|
|
1928
|
+
...daemonEcdhPublicKey && { daemonEcdhPublicKey }
|
|
1929
|
+
});
|
|
1530
1930
|
return;
|
|
1531
1931
|
}
|
|
1532
1932
|
if (isSignableAction(parsed.data.type)) {
|
|
@@ -1640,10 +2040,14 @@ async function dispatch(conn, message, {
|
|
|
1640
2040
|
case "session.insertText": {
|
|
1641
2041
|
const session = getSessionOrError(sessionManager, conn, message.sessionId);
|
|
1642
2042
|
if (!session) return;
|
|
2043
|
+
const { text, strippedCount } = sanitizeInsertText(message.text);
|
|
2044
|
+
if (strippedCount > 0) {
|
|
2045
|
+
console.warn(`bondsmithd: stripped ${strippedCount} control characters from inserted text`);
|
|
2046
|
+
}
|
|
1643
2047
|
try {
|
|
1644
2048
|
await sendKeysLiteral(
|
|
1645
2049
|
session.tmuxSessionName,
|
|
1646
|
-
`${BRACKETED_PASTE_START}${
|
|
2050
|
+
`${BRACKETED_PASTE_START}${text}${BRACKETED_PASTE_END}`
|
|
1647
2051
|
);
|
|
1648
2052
|
} catch (err) {
|
|
1649
2053
|
conn.send(error(errorMessageOf(err, "failed to insert text")));
|
|
@@ -1782,206 +2186,6 @@ async function dispatch(conn, message, {
|
|
|
1782
2186
|
}
|
|
1783
2187
|
}
|
|
1784
2188
|
|
|
1785
|
-
// src/security/connection-auth.ts
|
|
1786
|
-
import { randomBytes, verify as verify3 } from "crypto";
|
|
1787
|
-
var CHALLENGE_LENGTH_BYTES = 32;
|
|
1788
|
-
var CONNECTION_ID_LENGTH_BYTES = 24;
|
|
1789
|
-
var CHALLENGE_LIFETIME_MS = 3e4;
|
|
1790
|
-
var MAX_HANDSHAKE_ATTEMPTS_PER_WINDOW = 30;
|
|
1791
|
-
var HANDSHAKE_RATE_LIMIT_WINDOW_MS = 6e4;
|
|
1792
|
-
var handshakeAttemptTimestamps = [];
|
|
1793
|
-
function isHandshakeRateLimited() {
|
|
1794
|
-
const windowStart = Date.now() - HANDSHAKE_RATE_LIMIT_WINDOW_MS;
|
|
1795
|
-
handshakeAttemptTimestamps = handshakeAttemptTimestamps.filter((t) => t > windowStart);
|
|
1796
|
-
return handshakeAttemptTimestamps.length >= MAX_HANDSHAKE_ATTEMPTS_PER_WINDOW;
|
|
1797
|
-
}
|
|
1798
|
-
function recordHandshakeAttempt() {
|
|
1799
|
-
handshakeAttemptTimestamps.push(Date.now());
|
|
1800
|
-
}
|
|
1801
|
-
var ConnectionAuthState = class {
|
|
1802
|
-
constructor(daemonFingerprint) {
|
|
1803
|
-
this.daemonFingerprint = daemonFingerprint;
|
|
1804
|
-
this.connectionId = randomBytes(CONNECTION_ID_LENGTH_BYTES).toString("base64url");
|
|
1805
|
-
this.challenge = randomBytes(CHALLENGE_LENGTH_BYTES).toString("base64url");
|
|
1806
|
-
this.challengeExpiresAt = Date.now() + CHALLENGE_LIFETIME_MS;
|
|
1807
|
-
}
|
|
1808
|
-
connectionId;
|
|
1809
|
-
challenge;
|
|
1810
|
-
challengeExpiresAt;
|
|
1811
|
-
authenticated = null;
|
|
1812
|
-
lastSequence = 0;
|
|
1813
|
-
// BD-487: set synchronously by revocation (server.ts's
|
|
1814
|
-
// revokeControllerConnections/revokeAllConnections, or relay-client.ts's
|
|
1815
|
-
// identically-named functions), checked synchronously again in
|
|
1816
|
-
// router.ts's handleBrowserMessage immediately before dispatch() runs —
|
|
1817
|
-
// closes the gap where a command already past verifyCommand's async
|
|
1818
|
-
// awaits (getTrustedController's disk read, checkAndAdvanceSequence)
|
|
1819
|
-
// would otherwise still execute even though revocation was supposed to
|
|
1820
|
-
// have already ended this connection's authority. Deliberately a plain
|
|
1821
|
-
// synchronous flag, not itself awaited anywhere: the race this closes is
|
|
1822
|
-
// specifically the gap between an async check's awaits, so the fix must
|
|
1823
|
-
// be a check with zero await distance from dispatch(), not another
|
|
1824
|
-
// async lookup that reopens the same kind of window.
|
|
1825
|
-
revoked = false;
|
|
1826
|
-
// Serializes sequence verify-and-advance within this one connection —
|
|
1827
|
-
// without this, two commands signed close together could have their
|
|
1828
|
-
// async verification interleave and both read the same "last sequence"
|
|
1829
|
-
// before either advances it, letting a lower/equal sequence slip through
|
|
1830
|
-
// as if it were the next one. A per-connection promise chain (same
|
|
1831
|
-
// pattern as trusted-controllers.ts's withTrustFileLock, but in-memory
|
|
1832
|
-
// and scoped to one socket instead of a file path) is sufficient — never
|
|
1833
|
-
// contends across connections, and there is no file I/O to race.
|
|
1834
|
-
sequenceChain = Promise.resolve();
|
|
1835
|
-
// Serializes authenticate() the same way sequenceChain serializes
|
|
1836
|
-
// checkAndAdvanceSequence — adversarial review (BD-477 follow-up) found
|
|
1837
|
-
// that two connection.authenticate frames arriving back-to-back on one
|
|
1838
|
-
// socket could both pass the `this.authenticated === null` check before
|
|
1839
|
-
// either reached the assignment at the end of the async verification
|
|
1840
|
-
// chain, since getTrustedController is a real await gap. Both attempts
|
|
1841
|
-
// still require a validly-signed transcript (this was never an auth
|
|
1842
|
-
// bypass), but the second could silently overwrite the first's identity
|
|
1843
|
-
// and double-register the socket in server.ts's connectionsByController
|
|
1844
|
-
// map. Without a chain here, the fix would need a synchronous
|
|
1845
|
-
// compare-and-set instead, which would need to happen before the
|
|
1846
|
-
// signature is even checked — this chain is simpler and matches the
|
|
1847
|
-
// sequence check's own established pattern.
|
|
1848
|
-
authenticateChain = Promise.resolve();
|
|
1849
|
-
getChallenge() {
|
|
1850
|
-
return {
|
|
1851
|
-
connectionId: this.connectionId,
|
|
1852
|
-
challenge: this.challenge,
|
|
1853
|
-
expiresAt: this.challengeExpiresAt
|
|
1854
|
-
};
|
|
1855
|
-
}
|
|
1856
|
-
isAuthenticated() {
|
|
1857
|
-
return this.authenticated !== null;
|
|
1858
|
-
}
|
|
1859
|
-
/** BD-487: marks this connection's authority as ended — called by revocation, checked by router.ts immediately before dispatch(). Idempotent; a connection is never un-revoked. */
|
|
1860
|
-
revoke() {
|
|
1861
|
-
this.revoked = true;
|
|
1862
|
-
}
|
|
1863
|
-
isRevoked() {
|
|
1864
|
-
return this.revoked;
|
|
1865
|
-
}
|
|
1866
|
-
/** The controller/account this connection authenticated as, or null before a successful handshake. Used by verify-command.ts to reject an envelope signed by a DIFFERENT controller than the one that authenticated this connection. */
|
|
1867
|
-
getAuthenticatedIdentity() {
|
|
1868
|
-
return this.authenticated;
|
|
1869
|
-
}
|
|
1870
|
-
/**
|
|
1871
|
-
* Verifies a signed connection-handshake transcript and, on success,
|
|
1872
|
-
* marks this connection authenticated — exactly once, including under
|
|
1873
|
-
* concurrent calls. A second call (whether the first succeeded or
|
|
1874
|
-
* failed, and regardless of whether the two calls overlap in time) is
|
|
1875
|
-
* rejected with "already_authenticated": the challenge is bound to this
|
|
1876
|
-
* ConnectionAuthState instance and is never reissued or extended, so a
|
|
1877
|
-
* duplicate, delayed, or concurrent handshake attempt can never
|
|
1878
|
-
* re-authenticate or reset this connection's sequence counter.
|
|
1879
|
-
*
|
|
1880
|
-
* Serialized via authenticateChain (same pattern as
|
|
1881
|
-
* checkAndAdvanceSequence's sequenceChain) so two connection.authenticate
|
|
1882
|
-
* frames arriving close together on one socket can't both observe
|
|
1883
|
-
* `this.authenticated === null` before either finishes — without this,
|
|
1884
|
-
* both could pass the guard, both complete real signature verification
|
|
1885
|
-
* (this was never an auth bypass on its own), and the second's
|
|
1886
|
-
* assignment would silently overwrite the first's, double-registering
|
|
1887
|
-
* the socket under two controller identities.
|
|
1888
|
-
*
|
|
1889
|
-
* Checks run in a fixed order regardless of which field is
|
|
1890
|
-
* attacker-controlled, matching verify-command.ts's own discipline:
|
|
1891
|
-
* connection binding and freshness are checked before the signature is
|
|
1892
|
-
* even verified (they're cheap, local, and don't leak anything about
|
|
1893
|
-
* controller trust), then the signature, then controller trust —
|
|
1894
|
-
* signature must pass before ANY other field of the transcript is
|
|
1895
|
-
* trusted for a decision, since those fields are attacker-controlled
|
|
1896
|
-
* until the signature over the whole transcript is checked.
|
|
1897
|
-
*/
|
|
1898
|
-
authenticate(signed, trustedControllersPath) {
|
|
1899
|
-
const task = this.authenticateChain.then(
|
|
1900
|
-
() => this.authenticateOnce(signed, trustedControllersPath)
|
|
1901
|
-
);
|
|
1902
|
-
this.authenticateChain = task.catch(() => {
|
|
1903
|
-
});
|
|
1904
|
-
return task;
|
|
1905
|
-
}
|
|
1906
|
-
async authenticateOnce(signed, trustedControllersPath) {
|
|
1907
|
-
if (this.authenticated) {
|
|
1908
|
-
return { ok: false, reason: "already_authenticated" };
|
|
1909
|
-
}
|
|
1910
|
-
if (isHandshakeRateLimited()) {
|
|
1911
|
-
return { ok: false, reason: "rate_limited" };
|
|
1912
|
-
}
|
|
1913
|
-
recordHandshakeAttempt();
|
|
1914
|
-
const { transcript, signature } = signed;
|
|
1915
|
-
if (transcript.purpose !== CONNECTION_CHALLENGE_PURPOSE) {
|
|
1916
|
-
return { ok: false, reason: "bad_signature" };
|
|
1917
|
-
}
|
|
1918
|
-
if (transcript.connectionId !== this.connectionId || transcript.challenge !== this.challenge) {
|
|
1919
|
-
return { ok: false, reason: "wrong_connection" };
|
|
1920
|
-
}
|
|
1921
|
-
if (Date.now() >= this.challengeExpiresAt) {
|
|
1922
|
-
return { ok: false, reason: "challenge_expired" };
|
|
1923
|
-
}
|
|
1924
|
-
if (transcript.daemonFingerprint !== this.daemonFingerprint) {
|
|
1925
|
-
return { ok: false, reason: "wrong_daemon" };
|
|
1926
|
-
}
|
|
1927
|
-
const controller = await getTrustedController(transcript.controllerId, trustedControllersPath);
|
|
1928
|
-
if (!controller) {
|
|
1929
|
-
return { ok: false, reason: "untrusted_controller" };
|
|
1930
|
-
}
|
|
1931
|
-
let signatureValid;
|
|
1932
|
-
try {
|
|
1933
|
-
signatureValid = verify3(
|
|
1934
|
-
"sha256",
|
|
1935
|
-
Buffer.from(canonicalize(transcript), "utf8"),
|
|
1936
|
-
// WebCrypto's ECDSA signatures are raw r||s (IEEE P1363), not the
|
|
1937
|
-
// DER encoding node:crypto defaults to for EC keys — matches
|
|
1938
|
-
// verify-command.ts's and pairing.ts's identical handling.
|
|
1939
|
-
{ key: controller.publicKeyPem, dsaEncoding: "ieee-p1363" },
|
|
1940
|
-
Buffer.from(signature, "base64")
|
|
1941
|
-
);
|
|
1942
|
-
} catch {
|
|
1943
|
-
signatureValid = false;
|
|
1944
|
-
}
|
|
1945
|
-
if (!signatureValid) {
|
|
1946
|
-
return { ok: false, reason: "bad_signature" };
|
|
1947
|
-
}
|
|
1948
|
-
this.authenticated = { controllerId: transcript.controllerId, accountId: transcript.accountId };
|
|
1949
|
-
recordControllerHandshake(transcript.controllerId, trustedControllersPath).catch(
|
|
1950
|
-
(err) => {
|
|
1951
|
-
console.error("Failed to record controller handshake (non-fatal)", err);
|
|
1952
|
-
}
|
|
1953
|
-
);
|
|
1954
|
-
return { ok: true, controllerId: transcript.controllerId, accountId: transcript.accountId };
|
|
1955
|
-
}
|
|
1956
|
-
/**
|
|
1957
|
-
* Verifies a command envelope's sequence number is strictly greater than
|
|
1958
|
-
* the last one accepted on THIS connection, then advances the
|
|
1959
|
-
* counter — serialized via sequenceChain so two verify calls racing
|
|
1960
|
-
* close together can never both read the same lastSequence before
|
|
1961
|
-
* either advances it. Must only be called for an envelope whose
|
|
1962
|
-
* connectionId has already been confirmed to match this connection
|
|
1963
|
-
* (router.ts checks that before calling this) and whose controllerId
|
|
1964
|
-
* matches this connection's authenticated controller (same caller
|
|
1965
|
-
* responsibility) — this method only ever checks the sequence number
|
|
1966
|
-
* itself, not connection/controller binding, which is the caller's job.
|
|
1967
|
-
*/
|
|
1968
|
-
async checkAndAdvanceSequence(sequence) {
|
|
1969
|
-
if (!this.authenticated) {
|
|
1970
|
-
return { ok: false, reason: "not_authenticated" };
|
|
1971
|
-
}
|
|
1972
|
-
const task = this.sequenceChain.then(() => {
|
|
1973
|
-
if (sequence <= this.lastSequence) {
|
|
1974
|
-
return { ok: false, reason: "replayed" };
|
|
1975
|
-
}
|
|
1976
|
-
this.lastSequence = sequence;
|
|
1977
|
-
return { ok: true };
|
|
1978
|
-
});
|
|
1979
|
-
this.sequenceChain = task.catch(() => {
|
|
1980
|
-
});
|
|
1981
|
-
return task;
|
|
1982
|
-
}
|
|
1983
|
-
};
|
|
1984
|
-
|
|
1985
2189
|
// src/relay/relay-session-registry.ts
|
|
1986
2190
|
var MAX_CONCURRENT_RELAY_SESSIONS = 32;
|
|
1987
2191
|
var RelaySessionRegistry = class {
|
|
@@ -2034,20 +2238,31 @@ function signTranscript(transcript, privateKeyPem) {
|
|
|
2034
2238
|
}
|
|
2035
2239
|
function startRelayClient(deps) {
|
|
2036
2240
|
const sessions = new RelaySessionRegistry();
|
|
2241
|
+
const unauthenticatedTimers = /* @__PURE__ */ new Map();
|
|
2242
|
+
function forgetSession(relaySessionId) {
|
|
2243
|
+
clearTimeout(unauthenticatedTimers.get(relaySessionId));
|
|
2244
|
+
unauthenticatedTimers.delete(relaySessionId);
|
|
2245
|
+
sessions.delete(relaySessionId);
|
|
2246
|
+
}
|
|
2247
|
+
function forgetAllSessions() {
|
|
2248
|
+
for (const timer of unauthenticatedTimers.values()) clearTimeout(timer);
|
|
2249
|
+
unauthenticatedTimers.clear();
|
|
2250
|
+
sessions.deleteAll();
|
|
2251
|
+
}
|
|
2037
2252
|
let stopped = false;
|
|
2038
2253
|
let socket;
|
|
2039
2254
|
let reconnectAttempt = 0;
|
|
2040
2255
|
let reconnectTimer;
|
|
2041
2256
|
function broadcast(message) {
|
|
2042
2257
|
for (const entry of sessions.values()) {
|
|
2043
|
-
entry.conn.send(message);
|
|
2258
|
+
if (entry.connectionAuth.canReceiveBroadcasts()) entry.conn.send(message);
|
|
2044
2259
|
}
|
|
2045
2260
|
}
|
|
2046
2261
|
const routerDeps = { ...deps, broadcast };
|
|
2047
2262
|
function revokeSession(relaySessionId) {
|
|
2048
2263
|
sessions.get(relaySessionId)?.connectionAuth.revoke();
|
|
2049
2264
|
if (socket) send(socket, { type: "relay.closeSession", relaySessionId });
|
|
2050
|
-
|
|
2265
|
+
forgetSession(relaySessionId);
|
|
2051
2266
|
}
|
|
2052
2267
|
function revokeControllerConnections(controllerId) {
|
|
2053
2268
|
for (const entry of sessions.values()) {
|
|
@@ -2104,6 +2319,13 @@ function startRelayClient(deps) {
|
|
|
2104
2319
|
(connectionAuth) => buildRouterConnection(relaySessionId, ws, connectionAuth)
|
|
2105
2320
|
);
|
|
2106
2321
|
const entry = sessions.get(relaySessionId);
|
|
2322
|
+
const unauthenticatedTimer = setTimeout(() => {
|
|
2323
|
+
if (sessions.get(relaySessionId) === entry && !entry.connectionAuth.isAuthenticated()) {
|
|
2324
|
+
revokeSession(relaySessionId);
|
|
2325
|
+
}
|
|
2326
|
+
}, deps.unauthenticatedTimeoutMs ?? UNAUTHENTICATED_CONNECTION_TIMEOUT_MS);
|
|
2327
|
+
unauthenticatedTimer.unref();
|
|
2328
|
+
unauthenticatedTimers.set(relaySessionId, unauthenticatedTimer);
|
|
2107
2329
|
send(ws, { type: "relay.sessionOpenAck", relaySessionId });
|
|
2108
2330
|
const challenge = entry.connectionAuth.getChallenge();
|
|
2109
2331
|
entry.conn.send({
|
|
@@ -2120,7 +2342,11 @@ function startRelayClient(deps) {
|
|
|
2120
2342
|
function handleFrame(frame) {
|
|
2121
2343
|
const entry = sessions.get(frame.relaySessionId);
|
|
2122
2344
|
if (!entry) return;
|
|
2123
|
-
handleBrowserMessage(entry.conn, frame.payload, routerDeps).
|
|
2345
|
+
handleBrowserMessage(entry.conn, frame.payload, routerDeps).then(() => {
|
|
2346
|
+
if (entry.connectionAuth.hasExhaustedHandshakeAttempts()) {
|
|
2347
|
+
revokeSession(frame.relaySessionId);
|
|
2348
|
+
}
|
|
2349
|
+
}).catch((err) => {
|
|
2124
2350
|
console.error("relay-client: unhandled error in handleBrowserMessage", err);
|
|
2125
2351
|
entry.conn.send({
|
|
2126
2352
|
type: "error",
|
|
@@ -2144,6 +2370,13 @@ function startRelayClient(deps) {
|
|
|
2144
2370
|
const message = result.data;
|
|
2145
2371
|
switch (message.type) {
|
|
2146
2372
|
case "relay.challenge": {
|
|
2373
|
+
if (deps.expectedRelayAudience !== null && message.relayAudience !== deps.expectedRelayAudience) {
|
|
2374
|
+
console.error(
|
|
2375
|
+
`relay-client: refusing to register \u2014 relay presented audience "${message.relayAudience}", expected "${deps.expectedRelayAudience}"`
|
|
2376
|
+
);
|
|
2377
|
+
ws.close();
|
|
2378
|
+
return;
|
|
2379
|
+
}
|
|
2147
2380
|
const transcript = {
|
|
2148
2381
|
version: 1,
|
|
2149
2382
|
purpose: RELAY_REGISTER_PURPOSE,
|
|
@@ -2178,7 +2411,7 @@ function startRelayClient(deps) {
|
|
|
2178
2411
|
return;
|
|
2179
2412
|
}
|
|
2180
2413
|
case "relay.sessionClosed": {
|
|
2181
|
-
|
|
2414
|
+
forgetSession(message.relaySessionId);
|
|
2182
2415
|
return;
|
|
2183
2416
|
}
|
|
2184
2417
|
case "relay.frame": {
|
|
@@ -2190,7 +2423,7 @@ function startRelayClient(deps) {
|
|
|
2190
2423
|
ws.on("error", () => {
|
|
2191
2424
|
});
|
|
2192
2425
|
ws.on("close", () => {
|
|
2193
|
-
|
|
2426
|
+
forgetAllSessions();
|
|
2194
2427
|
if (socket === ws) socket = void 0;
|
|
2195
2428
|
scheduleReconnect();
|
|
2196
2429
|
});
|
|
@@ -2200,7 +2433,7 @@ function startRelayClient(deps) {
|
|
|
2200
2433
|
stop: () => {
|
|
2201
2434
|
stopped = true;
|
|
2202
2435
|
if (reconnectTimer) clearTimeout(reconnectTimer);
|
|
2203
|
-
|
|
2436
|
+
forgetAllSessions();
|
|
2204
2437
|
socket?.close();
|
|
2205
2438
|
},
|
|
2206
2439
|
broadcast,
|
|
@@ -2274,6 +2507,12 @@ function extractPemBlock(text, label) {
|
|
|
2274
2507
|
return text.match(pattern)?.[0] ?? null;
|
|
2275
2508
|
}
|
|
2276
2509
|
|
|
2510
|
+
// src/security/daemon-proof.ts
|
|
2511
|
+
import { sign as sign3 } from "crypto";
|
|
2512
|
+
function createDaemonProofSigner(privateKeyPem) {
|
|
2513
|
+
return (transcript) => sign3(null, Buffer.from(canonicalize(transcript), "utf8"), privateKeyPem).toString("base64");
|
|
2514
|
+
}
|
|
2515
|
+
|
|
2277
2516
|
// src/security/dev-tunnel-token-file.ts
|
|
2278
2517
|
import { randomBytes as randomBytes2 } from "crypto";
|
|
2279
2518
|
import { mkdir as mkdir3, readFile as readFile3, rename as rename3, rm as rm3, writeFile as writeFile3 } from "fs/promises";
|
|
@@ -2516,12 +2755,17 @@ var PendingAuthorizationStore = class {
|
|
|
2516
2755
|
};
|
|
2517
2756
|
|
|
2518
2757
|
// src/security/revocation-sync.ts
|
|
2758
|
+
import { z as z12 } from "zod";
|
|
2519
2759
|
var POLL_INTERVAL_MS = 3e4;
|
|
2520
2760
|
var POLL_JITTER_MS = 6e3;
|
|
2521
2761
|
var POLL_REQUEST_TIMEOUT_MS = 1e4;
|
|
2522
2762
|
var BACKOFF_BASE_MS = 2e3;
|
|
2523
2763
|
var BACKOFF_MAX_MS = 2e4;
|
|
2524
2764
|
var MAX_CONSECUTIVE_FAILURE_BACKOFFS = 4;
|
|
2765
|
+
var checkRevocationStatusResponseSchema = z12.object({
|
|
2766
|
+
pairingRevoked: z12.boolean(),
|
|
2767
|
+
revokedControllerPublicKeys: z12.array(z12.string())
|
|
2768
|
+
});
|
|
2525
2769
|
function jitteredInterval(deps) {
|
|
2526
2770
|
const interval = deps.pollIntervalMs ?? POLL_INTERVAL_MS;
|
|
2527
2771
|
const jitter = deps.pollJitterMs ?? POLL_JITTER_MS;
|
|
@@ -2543,17 +2787,22 @@ async function pollOnce(deps) {
|
|
|
2543
2787
|
if (trusted.length === 0) return true;
|
|
2544
2788
|
let response;
|
|
2545
2789
|
try {
|
|
2546
|
-
|
|
2547
|
-
callTRPCQuery(
|
|
2548
|
-
deps.
|
|
2549
|
-
|
|
2550
|
-
|
|
2551
|
-
daemonPublicKey: deps.daemonPublicKey,
|
|
2552
|
-
controllerPublicKeys: trusted.map((c) => c.publicKeyPem)
|
|
2553
|
-
}
|
|
2554
|
-
),
|
|
2790
|
+
const raw = await withTimeout(
|
|
2791
|
+
callTRPCQuery(deps.apiUrl, "daemonPairing.checkRevocationStatus", {
|
|
2792
|
+
daemonPublicKey: deps.daemonPublicKey,
|
|
2793
|
+
controllerPublicKeys: trusted.map((c) => c.publicKeyPem)
|
|
2794
|
+
}),
|
|
2555
2795
|
deps.pollRequestTimeoutMs ?? POLL_REQUEST_TIMEOUT_MS
|
|
2556
2796
|
);
|
|
2797
|
+
const parsed = checkRevocationStatusResponseSchema.safeParse(raw);
|
|
2798
|
+
if (!parsed.success) {
|
|
2799
|
+
console.error(
|
|
2800
|
+
"revocation-sync: ignoring malformed checkRevocationStatus response",
|
|
2801
|
+
parsed.error.message
|
|
2802
|
+
);
|
|
2803
|
+
return false;
|
|
2804
|
+
}
|
|
2805
|
+
response = parsed.data;
|
|
2557
2806
|
} catch {
|
|
2558
2807
|
return false;
|
|
2559
2808
|
}
|
|
@@ -3317,7 +3566,9 @@ function stderrOf(error2) {
|
|
|
3317
3566
|
}
|
|
3318
3567
|
async function createWorktree(sourceCwd, worktreePath, branch) {
|
|
3319
3568
|
try {
|
|
3320
|
-
await execFile3("git", ["worktree", "add", "-b", branch, worktreePath], {
|
|
3569
|
+
await execFile3("git", [...SAFE_GIT_CONFIG, "worktree", "add", "-b", branch, worktreePath], {
|
|
3570
|
+
cwd: sourceCwd
|
|
3571
|
+
});
|
|
3321
3572
|
} catch (error2) {
|
|
3322
3573
|
const stderr = stderrOf(error2);
|
|
3323
3574
|
if (stderr.includes("a branch named")) {
|
|
@@ -3331,7 +3582,9 @@ async function createWorktree(sourceCwd, worktreePath, branch) {
|
|
|
3331
3582
|
}
|
|
3332
3583
|
async function removeWorktree(sourceCwd, worktreePath) {
|
|
3333
3584
|
try {
|
|
3334
|
-
await execFile3("git", ["worktree", "remove", worktreePath], {
|
|
3585
|
+
await execFile3("git", [...SAFE_GIT_CONFIG, "worktree", "remove", worktreePath], {
|
|
3586
|
+
cwd: sourceCwd
|
|
3587
|
+
});
|
|
3335
3588
|
} catch (error2) {
|
|
3336
3589
|
const stderr = stderrOf(error2);
|
|
3337
3590
|
if (stderr.includes("contains modified or untracked files")) {
|
|
@@ -3345,6 +3598,7 @@ async function removeWorktree(sourceCwd, worktreePath) {
|
|
|
3345
3598
|
var LIVE_SESSION_REPORTS_FORMAT = "#{session_name} #{@bondsmith-agent-state} #{window_bell_flag}";
|
|
3346
3599
|
var AGENT_STATE_OUTPUT_GRACE_MS = 2e3;
|
|
3347
3600
|
var FUTURE_REPORT_ALLOWANCE_MS = 2e3;
|
|
3601
|
+
var WORKING_SILENCE_MS = 12e4;
|
|
3348
3602
|
var SETTLED_STATES = /* @__PURE__ */ new Set(["waiting", "done"]);
|
|
3349
3603
|
function isAgentState(value) {
|
|
3350
3604
|
return AGENT_STATES.includes(value);
|
|
@@ -3366,6 +3620,10 @@ function resolveAgentState(raw, lastActivityAt, nowMs = Date.now()) {
|
|
|
3366
3620
|
if (SETTLED_STATES.has(state) && lastActivityAt) {
|
|
3367
3621
|
if (Date.parse(lastActivityAt) > reportedAtMs + AGENT_STATE_OUTPUT_GRACE_MS) return null;
|
|
3368
3622
|
}
|
|
3623
|
+
if (state === "working" && lastActivityAt) {
|
|
3624
|
+
const quietSinceMs = Math.max(Date.parse(lastActivityAt), reportedAtMs);
|
|
3625
|
+
if (nowMs - quietSinceMs > WORKING_SILENCE_MS) return null;
|
|
3626
|
+
}
|
|
3369
3627
|
return state;
|
|
3370
3628
|
}
|
|
3371
3629
|
function resolveSessionAttention(report, lastActivityAt, nowMs) {
|
|
@@ -3407,6 +3665,7 @@ async function createTmuxSession(input) {
|
|
|
3407
3665
|
await runTmux(["new-session", "-d", "-s", tmuxSessionName, "-c", cwd, input.command], {
|
|
3408
3666
|
env: filterSpawnEnv(process.env)
|
|
3409
3667
|
});
|
|
3668
|
+
await runTmux(["set-option", "-t", tmuxSessionName, "status", "off"]);
|
|
3410
3669
|
await runTmux(["set-option", "-t", tmuxSessionName, "@bondsmith-id", input.id]);
|
|
3411
3670
|
await runTmux(["set-option", "-t", tmuxSessionName, "@bondsmith-title", input.title]);
|
|
3412
3671
|
await runTmux(["set-option", "-t", tmuxSessionName, "@bondsmith-command", input.command]);
|
|
@@ -3657,7 +3916,7 @@ function snapshotEquals(a, b) {
|
|
|
3657
3916
|
}
|
|
3658
3917
|
|
|
3659
3918
|
// src/ws/server.ts
|
|
3660
|
-
function createDaemonWebSocketServer(httpServer, config, daemonFingerprint, pendingAuthorizations, presentPendingAuthorization, trustedControllersPath) {
|
|
3919
|
+
function createDaemonWebSocketServer(httpServer, config, daemonFingerprint, pendingAuthorizations, presentPendingAuthorization, signDaemonProof, trustedControllersPath, unauthenticatedTimeoutMs = UNAUTHENTICATED_CONNECTION_TIMEOUT_MS) {
|
|
3661
3920
|
const sessionManager = new SessionManager();
|
|
3662
3921
|
const ptyAttachments = new PtyAttachmentManager();
|
|
3663
3922
|
const connectionsByController = /* @__PURE__ */ new Map();
|
|
@@ -3714,7 +3973,9 @@ function createDaemonWebSocketServer(httpServer, config, daemonFingerprint, pend
|
|
|
3714
3973
|
function broadcast(message) {
|
|
3715
3974
|
const payload = JSON.stringify(message);
|
|
3716
3975
|
for (const client of wss.clients) {
|
|
3717
|
-
if (client.readyState === client.OPEN
|
|
3976
|
+
if (client.readyState === client.OPEN && connectionAuthBySocket.get(client)?.canReceiveBroadcasts()) {
|
|
3977
|
+
client.send(payload);
|
|
3978
|
+
}
|
|
3718
3979
|
}
|
|
3719
3980
|
}
|
|
3720
3981
|
sessionManager.onSessionUpdated((session) => {
|
|
@@ -3754,6 +4015,10 @@ function createDaemonWebSocketServer(httpServer, config, daemonFingerprint, pend
|
|
|
3754
4015
|
protocolVersion: PROTOCOL_VERSION,
|
|
3755
4016
|
daemonVersion: DAEMON_VERSION
|
|
3756
4017
|
});
|
|
4018
|
+
const unauthenticatedTimer = setTimeout(() => {
|
|
4019
|
+
if (!connectionAuth.isAuthenticated()) socket.close();
|
|
4020
|
+
}, unauthenticatedTimeoutMs);
|
|
4021
|
+
unauthenticatedTimer.unref();
|
|
3757
4022
|
socket.on("message", (raw) => {
|
|
3758
4023
|
let parsed;
|
|
3759
4024
|
try {
|
|
@@ -3774,7 +4039,10 @@ function createDaemonWebSocketServer(httpServer, config, daemonFingerprint, pend
|
|
|
3774
4039
|
daemonFingerprint,
|
|
3775
4040
|
pendingAuthorizations,
|
|
3776
4041
|
presentPendingAuthorization,
|
|
4042
|
+
signDaemonProof,
|
|
3777
4043
|
trustedControllersPath
|
|
4044
|
+
}).then(() => {
|
|
4045
|
+
if (connectionAuth.hasExhaustedHandshakeAttempts()) socket.close();
|
|
3778
4046
|
}).catch((err) => {
|
|
3779
4047
|
console.error("Unhandled error in handleBrowserMessage", err);
|
|
3780
4048
|
conn.send({
|
|
@@ -3784,6 +4052,7 @@ function createDaemonWebSocketServer(httpServer, config, daemonFingerprint, pend
|
|
|
3784
4052
|
});
|
|
3785
4053
|
});
|
|
3786
4054
|
socket.on("close", () => {
|
|
4055
|
+
clearTimeout(unauthenticatedTimer);
|
|
3787
4056
|
releaseAllSubscriptions();
|
|
3788
4057
|
cleanupBySocket.delete(socket);
|
|
3789
4058
|
connectionAuthBySocket.delete(socket);
|
|
@@ -3825,6 +4094,7 @@ async function start() {
|
|
|
3825
4094
|
}
|
|
3826
4095
|
const identity = await loadOrCreateDaemonIdentity();
|
|
3827
4096
|
const daemonFingerprint = await formatDaemonFingerprint(identity.publicKeyPem);
|
|
4097
|
+
const signDaemonProof = createDaemonProofSigner(identity.privateKeyPem);
|
|
3828
4098
|
const pendingAuthorizations = new PendingAuthorizationStore();
|
|
3829
4099
|
const presentPendingAuthorization = presentPendingAuthorizationOnTerminal(pendingAuthorizations);
|
|
3830
4100
|
await ensurePaired({
|
|
@@ -3854,11 +4124,13 @@ async function start() {
|
|
|
3854
4124
|
config,
|
|
3855
4125
|
daemonFingerprint,
|
|
3856
4126
|
pendingAuthorizations,
|
|
3857
|
-
presentPendingAuthorization
|
|
4127
|
+
presentPendingAuthorization,
|
|
4128
|
+
signDaemonProof
|
|
3858
4129
|
);
|
|
3859
4130
|
await sessionManager.initialize();
|
|
3860
4131
|
const relayClient = config.relayEnabled ? startRelayClient({
|
|
3861
4132
|
relayUrl: config.relayUrl,
|
|
4133
|
+
expectedRelayAudience: config.expectedRelayAudience,
|
|
3862
4134
|
daemonFingerprint,
|
|
3863
4135
|
daemonPublicKeyPem: identity.publicKeyPem,
|
|
3864
4136
|
daemonPrivateKeyPem: identity.privateKeyPem,
|
|
@@ -3870,7 +4142,8 @@ async function start() {
|
|
|
3870
4142
|
validateProjectPath,
|
|
3871
4143
|
hostname: hostname2,
|
|
3872
4144
|
pendingAuthorizations,
|
|
3873
|
-
presentPendingAuthorization
|
|
4145
|
+
presentPendingAuthorization,
|
|
4146
|
+
signDaemonProof
|
|
3874
4147
|
}) : void 0;
|
|
3875
4148
|
if (relayClient) {
|
|
3876
4149
|
sessionManager.onSessionUpdated((session) => {
|