@bivy/bivy 0.18.5-staging.5 → 0.18.5-staging.7

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.
@@ -0,0 +1,57 @@
1
+ /** User credentials (configured token or gh login) can reach repos outside the Apps. */
2
+ export async function listGithubUserRepos(token, fetchImpl = fetch) {
3
+ const repos = [];
4
+ for (let page = 1;; page++) {
5
+ const response = await fetchImpl(`https://api.github.com/user/repos?sort=updated&per_page=100&affiliation=owner,collaborator,organization_member&page=${page}`, {
6
+ headers: { authorization: `Bearer ${token}`, accept: "application/vnd.github+json", "user-agent": "bivy" },
7
+ });
8
+ if (!response.ok)
9
+ throw new Error(`GitHub responded ${response.status} while listing user repositories`);
10
+ const batch = await response.json();
11
+ if (!Array.isArray(batch))
12
+ throw new Error("GitHub returned an invalid repository listing");
13
+ for (const repo of batch) {
14
+ if (typeof repo?.full_name !== "string" || !repo.full_name)
15
+ continue;
16
+ repos.push({
17
+ slug: repo.full_name,
18
+ description: typeof repo.description === "string" ? repo.description : "",
19
+ private: Boolean(repo.private),
20
+ pushedAt: typeof repo.pushed_at === "string" ? repo.pushed_at : undefined,
21
+ defaultBranch: typeof repo.default_branch === "string" ? repo.default_branch : undefined,
22
+ });
23
+ }
24
+ if (batch.length < 100)
25
+ return repos;
26
+ }
27
+ }
28
+ /** Query both sources independently; a failed credential must not hide usable repos. */
29
+ export async function discoverGithubRepos(sources) {
30
+ const results = await Promise.allSettled([sources.appRepos(), sources.userRepos()]);
31
+ const repos = new Map();
32
+ const errors = [];
33
+ let authed = false;
34
+ for (const [index, result] of results.entries()) {
35
+ if (result.status === "rejected") {
36
+ errors.push(`${index === 0 ? "GitHub App" : "GitHub user"}: ${result.reason instanceof Error ? result.reason.message : String(result.reason)}`);
37
+ continue;
38
+ }
39
+ if (result.value === undefined)
40
+ continue;
41
+ authed = true;
42
+ for (const repo of result.value) {
43
+ const key = repo.slug.toLowerCase();
44
+ if (!repos.has(key))
45
+ repos.set(key, repo);
46
+ }
47
+ }
48
+ const listing = {
49
+ authed,
50
+ repos: [...repos.values()].sort((a, b) => (b.pushedAt ?? "").localeCompare(a.pushedAt ?? "") || a.slug.localeCompare(b.slug)),
51
+ };
52
+ if (errors.length)
53
+ listing.error = errors.join("; ");
54
+ else if (!authed)
55
+ listing.reason = await sources.ghInstalled() ? "gh-unauthed" : "no-token";
56
+ return listing;
57
+ }
@@ -1,5 +1,6 @@
1
1
  // SPDX-License-Identifier: AGPL-3.0-only
2
2
  // Copyright (c) 2026 Petter André Sjulstad
3
+ import { EventTransferReceiver } from "./remote/event-transfer-receiver.js";
3
4
  //
4
5
  // Relay-tunnelled attach bridge for `bivy run --node <account-node>`.
5
6
  //
@@ -155,7 +156,7 @@ async function main() {
155
156
  throw new Error("no relay URL available for the target node");
156
157
  // 3. open the relay /client socket in the target's room and pair.
157
158
  const rly = new WebSocket(`${relayBase}/client?ticket=${encodeURIComponent(ticket)}&nodeId=${encodeURIComponent(args.nodeId)}`);
158
- const reassembler = new FrameReassembler();
159
+ const reassembler = new FrameReassembler({ onReject: message => fail(message) });
159
160
  let cipher = null;
160
161
  let local = null;
161
162
  let paired = false;
@@ -170,6 +171,18 @@ async function main() {
170
171
  }
171
172
  process.exit(code);
172
173
  };
174
+ const transfers = new EventTransferReceiver({
175
+ send: async (command) => {
176
+ if (!cipher || rly.readyState !== WebSocket.OPEN)
177
+ throw new Error("Relay disconnected");
178
+ for (const frame of frameMessages(cipher.seal(command)))
179
+ rly.send(frame);
180
+ },
181
+ emit: event => { if (local?.readyState === WebSocket.OPEN)
182
+ local.send(JSON.stringify(event)); },
183
+ fault: message => fail(message),
184
+ });
185
+ rly.on("close", () => { transfers.reset(); reassembler.reset(); });
173
186
  const pairTimer = setTimeout(() => {
174
187
  if (!paired)
175
188
  fail(`Node "${args.nodeName}" did not respond to pairing — it may be offline.`, 1);
@@ -258,8 +271,7 @@ async function main() {
258
271
  return; // undecryptable frame — ignore
259
272
  }
260
273
  // Forward the bare terminal.* event to the local attach socket verbatim.
261
- if (local && local.readyState === WebSocket.OPEN)
262
- local.send(JSON.stringify(event));
274
+ transfers.accept(event);
263
275
  }
264
276
  });
265
277
  // Stand up the loopback /ws server + spawn attach, only once paired.
@@ -293,8 +305,8 @@ async function main() {
293
305
  rly.send(frame);
294
306
  }
295
307
  }
296
- catch {
297
- /* seal/send failure — the relay close handler will surface it */
308
+ catch (error) {
309
+ sock.send(JSON.stringify({ type: "session.error", code: "delivery_failed", error: error.message }));
298
310
  }
299
311
  });
300
312
  sock.on("close", () => {
@@ -11,6 +11,8 @@ export { FRAME_CHUNK_BYTES };
11
11
  * chunk messages for large ones.
12
12
  */
13
13
  export function frameMessages(payload) {
14
+ if (payload.length > MAX_REASSEMBLY_BYTES)
15
+ throw new Error("Relay payload exceeds the receiver limit; use a bounded transfer.");
14
16
  if (payload.length <= FRAME_CHUNK_BYTES) {
15
17
  return [JSON.stringify({ t: "frame", p: payload })];
16
18
  }
@@ -23,51 +25,82 @@ export function frameMessages(payload) {
23
25
  }
24
26
  return out;
25
27
  }
26
- /**
27
- * Buffers inbound frame chunks and yields the full payload once a group is
28
- * complete. A non-chunked frame passes straight through.
29
- */
30
- export class FrameReassembler {
31
- groups = new Map();
32
- /**
33
- * Feed one inbound frame envelope. Returns the reassembled payload string when
34
- * complete (or the payload itself when the frame was not chunked), or null
35
- * while more chunks are still outstanding / the frame was invalid.
36
- */
37
- accept(env) {
38
- if (typeof env.p !== "string")
28
+ /** null means incomplete or rejected; onReject distinguishes delivery failure.
29
+ * Limits apply to aggregate buffering as well as individual messages. */
30
+ export function createFrameReassembler(opts = {}) {
31
+ const groups = new Map();
32
+ const rejected = new Map();
33
+ const maxGroups = opts.maxGroups ?? MAX_REASSEMBLY_GROUPS;
34
+ const maxBytes = opts.maxBytes ?? MAX_REASSEMBLY_BYTES;
35
+ const timeoutMs = opts.timeoutMs ?? 30_000;
36
+ let buffered = 0;
37
+ const remove = (id) => {
38
+ const group = groups.get(id);
39
+ if (group) {
40
+ clearTimeout(group.timer);
41
+ buffered -= group.bytes;
42
+ groups.delete(id);
43
+ }
44
+ };
45
+ const reject = (id, reason) => {
46
+ remove(id);
47
+ if (rejected.size >= 256)
48
+ rejected.delete(rejected.keys().next().value);
49
+ rejected.set(id, Date.now() + timeoutMs);
50
+ opts.onReject?.(reason);
51
+ return null;
52
+ };
53
+ const accept = (env) => {
54
+ for (const [id, expires] of rejected)
55
+ if (expires <= Date.now())
56
+ rejected.delete(id);
57
+ if (!env || typeof env.p !== "string")
39
58
  return null;
40
59
  if (env.fc === undefined)
41
- return env.p; // not chunked
60
+ return env.p.length <= maxBytes ? env.p : reject("unchunked", "Relay response exceeds the receive limit.");
42
61
  const id = String(env.fc);
43
- const index = Number(env.fi);
44
- const total = Number(env.fn);
45
- if (!Number.isInteger(index) || !Number.isInteger(total) || total <= 0 || total > MAX_FRAME_CHUNKS || index < 0 || index >= total) {
62
+ if (rejected.has(id))
46
63
  return null;
47
- }
48
- let group = this.groups.get(id);
64
+ const i = Number(env.fi);
65
+ const n = Number(env.fn);
66
+ if (!Number.isInteger(i) || !Number.isInteger(n) || n <= 0 || n > MAX_FRAME_CHUNKS || i < 0 || i >= n)
67
+ return reject(id, "Invalid relay chunk metadata.");
68
+ let group = groups.get(id);
69
+ if (group && group.total !== n)
70
+ return reject(id, "Inconsistent relay chunk count.");
49
71
  if (!group) {
50
- // Bound concurrent groups; evict the oldest (insertion order) if needed.
51
- if (this.groups.size >= MAX_REASSEMBLY_GROUPS) {
52
- const oldest = this.groups.keys().next().value;
53
- if (oldest !== undefined)
54
- this.groups.delete(oldest);
55
- }
56
- group = { total, parts: new Array(total), have: 0, bytes: 0 };
57
- this.groups.set(id, group);
72
+ if (groups.size >= maxGroups)
73
+ return reject(id, "Too many incomplete relay responses.");
74
+ const timer = setTimeout(() => reject(id, "Relay response timed out before all chunks arrived."), timeoutMs);
75
+ timer.unref?.();
76
+ group = { total: n, parts: new Array(n), have: 0, bytes: 0, timer };
77
+ groups.set(id, group);
58
78
  }
59
- if (group.parts[index] !== undefined)
60
- return null; // duplicate chunk
61
- group.parts[index] = env.p;
62
- group.have += 1;
63
- group.bytes += env.p.length;
64
- if (group.bytes > MAX_REASSEMBLY_BYTES) {
65
- this.groups.delete(id);
79
+ if (group.parts[i] !== undefined)
66
80
  return null;
67
- }
81
+ if (buffered + env.p.length > maxBytes)
82
+ return reject(id, "Relay responses exceed the receive memory limit.");
83
+ group.parts[i] = env.p;
84
+ group.have++;
85
+ group.bytes += env.p.length;
86
+ buffered += env.p.length;
68
87
  if (group.have < group.total)
69
88
  return null;
70
- this.groups.delete(id);
71
- return group.parts.join("");
72
- }
89
+ const payload = group.parts.join("");
90
+ remove(id);
91
+ // Ignore straggling duplicates of a completed group instead of creating a
92
+ // new incomplete group that later reports a spurious timeout.
93
+ if (rejected.size >= 256)
94
+ rejected.delete(rejected.keys().next().value);
95
+ rejected.set(id, Date.now() + timeoutMs);
96
+ return payload;
97
+ };
98
+ return Object.assign(accept, { reset() { for (const id of groups.keys())
99
+ remove(id); rejected.clear(); } });
100
+ }
101
+ export class FrameReassembler {
102
+ acceptFrame;
103
+ constructor(options = {}) { this.acceptFrame = createFrameReassembler(options); }
104
+ accept(env) { return this.acceptFrame(env); }
105
+ reset() { this.acceptFrame.reset(); }
73
106
  }
@@ -0,0 +1,142 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ // Standalone node mirror of packages/core/src/event-transfer.ts. The release
4
+ // omits packages/; behavior is covered by the cross-stack transfer tests.
5
+ const unb64 = (data) => Uint8Array.from(atob(data), c => c.charCodeAt(0));
6
+ import { TRANSFER_PAGE_BYTES, MAX_TRANSFER_BYTES } from "../wire-format.js";
7
+ export { TRANSFER_PAGE_BYTES, MAX_TRANSFER_BYTES };
8
+ const MAX_QUEUED_BYTES = 8 * 1024 * 1024;
9
+ /** Deliver complete events in wire order. A history snapshot and its baseline
10
+ * are never exposed partially; later live events wait behind that snapshot.
11
+ * Only one page is in flight, with bounded retries and memory. */
12
+ export class EventTransferReceiver {
13
+ deps;
14
+ queue = [];
15
+ queuedBytes = 0;
16
+ pending;
17
+ timer;
18
+ generation = 0;
19
+ constructor(deps) {
20
+ this.deps = deps;
21
+ }
22
+ accept(event) {
23
+ if (event.type === "transfer.part") {
24
+ void this.part(event);
25
+ return;
26
+ }
27
+ if (this.pending || this.queue.length) {
28
+ this.queuedBytes += new TextEncoder().encode(JSON.stringify(event)).length;
29
+ if (this.queuedBytes > MAX_QUEUED_BYTES || this.queue.length >= 4096) {
30
+ this.reset();
31
+ this.deps.fault("Transcript delivery fell behind. Reconnecting to synchronize again.");
32
+ return;
33
+ }
34
+ this.queue.push(event);
35
+ return;
36
+ }
37
+ this.deliver(event);
38
+ }
39
+ deliver(event) {
40
+ if (event.code !== "transfer_required") {
41
+ this.deps.emit(event);
42
+ return;
43
+ }
44
+ const offer = event.transfer;
45
+ if (!offer || !/^[a-f0-9]{48}$/.test(offer.id) || !/^[a-f0-9]{64}$/.test(offer.key) || !/^[a-f0-9]{24}$/.test(offer.iv) ||
46
+ !Number.isSafeInteger(offer.bytes) || offer.bytes <= 0 || offer.bytes > MAX_TRANSFER_BYTES || offer.pageBytes !== TRANSFER_PAGE_BYTES) {
47
+ this.deps.emit({ type: "session.error", code: "delivery_invalid", sessionId: event.sessionId, requestId: event.requestId, error: "Invalid large-response transfer. Update the app and retry." });
48
+ return;
49
+ }
50
+ this.pending = { offer, envelope: event, bytes: new Uint8Array(offer.bytes), offset: 0, requestId: "", retries: 0 };
51
+ this.request();
52
+ }
53
+ request() {
54
+ const pending = this.pending;
55
+ if (!pending)
56
+ return;
57
+ pending.requestId = `transfer-${crypto.randomUUID()}`;
58
+ clearTimeout(this.timer);
59
+ this.timer = setTimeout(() => {
60
+ if (this.pending !== pending)
61
+ return;
62
+ if (++pending.retries <= 2)
63
+ this.request();
64
+ else
65
+ this.fail("Large-response delivery timed out. Retry to synchronize.");
66
+ }, this.deps.timeoutMs ?? 10_000);
67
+ void this.deps.send({ kind: "transfer.read", transferId: pending.offer.id, offset: pending.offset, requestId: pending.requestId })
68
+ .catch(() => { if (this.pending === pending)
69
+ this.fail("Could not request the next response page. Retry to synchronize."); });
70
+ }
71
+ async part(event) {
72
+ const pending = this.pending;
73
+ // Other paired clients use the same encrypted room; their replies are not ours.
74
+ if (!pending || event.requestId !== pending.requestId || event.transferId !== pending.offer.id || event.offset !== pending.offset)
75
+ return;
76
+ clearTimeout(this.timer);
77
+ if (typeof event.error === "string") {
78
+ this.fail(event.error);
79
+ return;
80
+ }
81
+ try {
82
+ if (typeof event.data !== "string" || event.data.length > Math.ceil(TRANSFER_PAGE_BYTES / 3) * 4)
83
+ throw new Error("Invalid response page.");
84
+ const bytes = unb64(event.data);
85
+ if (bytes.length !== Math.min(TRANSFER_PAGE_BYTES, pending.offer.bytes - pending.offset))
86
+ throw new Error("Incomplete response page.");
87
+ pending.bytes.set(bytes, pending.offset);
88
+ pending.offset += bytes.length;
89
+ pending.retries = 0;
90
+ if (pending.offset < pending.offer.bytes) {
91
+ this.request();
92
+ return;
93
+ }
94
+ // Reject duplicate final pages while the authenticated decryption is asynchronous.
95
+ pending.requestId = "";
96
+ const generation = this.generation;
97
+ const fromHex = (value) => Uint8Array.from(value.match(/../g), pair => parseInt(pair, 16));
98
+ const key = await crypto.subtle.importKey("raw", fromHex(pending.offer.key), "AES-GCM", false, ["decrypt"]);
99
+ let plaintext;
100
+ try {
101
+ plaintext = await crypto.subtle.decrypt({ name: "AES-GCM", iv: fromHex(pending.offer.iv) }, key, pending.bytes);
102
+ }
103
+ catch {
104
+ throw new Error("Response integrity check failed.");
105
+ }
106
+ if (generation !== this.generation || this.pending !== pending)
107
+ return;
108
+ const complete = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(plaintext));
109
+ if (!complete || typeof complete.type !== "string" || complete.code === "transfer_required" || complete.type === "transfer.part")
110
+ throw new Error("Invalid response envelope.");
111
+ this.pending = undefined;
112
+ this.deps.emit(complete);
113
+ this.drain();
114
+ }
115
+ catch (error) {
116
+ if (this.pending === pending)
117
+ this.fail(`${error.message} Retry to synchronize.`);
118
+ }
119
+ }
120
+ fail(message) {
121
+ const envelope = this.pending?.envelope;
122
+ this.reset();
123
+ this.deps.emit({ type: "session.error", code: "delivery_failed", sessionId: envelope?.sessionId, requestId: envelope?.requestId, error: message });
124
+ // Dropping queued events invalidates the stream. Reconnect triggers the
125
+ // existing history/replay recovery; never apply a tail as a full snapshot.
126
+ this.deps.fault(message);
127
+ }
128
+ drain() {
129
+ while (!this.pending && this.queue.length) {
130
+ const next = this.queue.shift();
131
+ this.queuedBytes -= new TextEncoder().encode(JSON.stringify(next)).length;
132
+ this.deliver(next);
133
+ }
134
+ }
135
+ reset() {
136
+ clearTimeout(this.timer);
137
+ this.pending = undefined;
138
+ this.queue = [];
139
+ this.queuedBytes = 0;
140
+ this.generation++;
141
+ }
142
+ }
@@ -0,0 +1,81 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ import { createCipheriv, randomBytes } from "node:crypto";
4
+ // A logical event is immutable while clients pull its pages. Pages are encoded
5
+ // independently, so neither history nor an individual attachment must fit in a
6
+ // single sealed frame. Keep in sync with core/event-transfer.ts (conformance test).
7
+ import { TRANSFER_PAGE_BYTES, MAX_TRANSFER_BYTES } from "../wire-format.js";
8
+ export { TRANSFER_PAGE_BYTES, MAX_TRANSFER_BYTES };
9
+ const MAX_STORED_BYTES = 128 * 1024 * 1024;
10
+ const TTL_MS = 120_000;
11
+ const INLINE_EVENT_BYTES = 4 * 1024 * 1024;
12
+ const MAX_LIFETIME_MS = 10 * 60_000;
13
+ export class EventTransferStore {
14
+ now;
15
+ inlineBytes;
16
+ entries = new Map();
17
+ bytes = 0;
18
+ timer;
19
+ constructor(now = Date.now, inlineBytes = INLINE_EVENT_BYTES) {
20
+ this.now = now;
21
+ this.inlineBytes = inlineBytes;
22
+ }
23
+ prune() {
24
+ for (const [id, entry] of this.entries)
25
+ if (entry.expires <= this.now()) {
26
+ this.bytes -= entry.body.length;
27
+ this.entries.delete(id);
28
+ }
29
+ }
30
+ offer(event) {
31
+ const body = Buffer.from(JSON.stringify(event));
32
+ if (body.length <= this.inlineBytes)
33
+ return null;
34
+ const source = event;
35
+ const context = { sessionId: source.sessionId, requestId: source.requestId };
36
+ this.prune();
37
+ if (body.length + 16 > MAX_TRANSFER_BYTES)
38
+ return {
39
+ type: "session.error", ...context, code: "delivery_too_large",
40
+ error: "This response exceeds the machine's 64 MiB delivery limit. The original data is retained on the machine.",
41
+ };
42
+ // Retain only authenticated ciphertext. This is response encryption, not
43
+ // a password digest or a second durable copy of credential-bearing events.
44
+ if (this.bytes + body.length + 16 > MAX_STORED_BYTES || this.entries.size >= 16)
45
+ return {
46
+ type: "session.error", ...context, code: "delivery_busy", error: "The machine is serving large responses. Please retry shortly.",
47
+ };
48
+ const id = randomBytes(24).toString("hex");
49
+ const key = randomBytes(32);
50
+ const iv = randomBytes(12);
51
+ const cipher = createCipheriv("aes-256-gcm", key, iv);
52
+ // WebCrypto's native AES-GCM layout is ciphertext followed by its tag.
53
+ const sealed = Buffer.concat([cipher.update(body), cipher.final(), cipher.getAuthTag()]);
54
+ this.entries.set(id, { body: sealed, key: key.toString("hex"), iv: iv.toString("hex"),
55
+ expires: this.now() + TTL_MS, deadline: this.now() + MAX_LIFETIME_MS });
56
+ this.bytes += sealed.length;
57
+ if (!this.timer) {
58
+ this.timer = setInterval(() => this.prune(), TTL_MS);
59
+ this.timer.unref();
60
+ }
61
+ // Legacy clients already display session.notice messages. Updated clients consume the
62
+ // transfer before the reducer sees this compatibility envelope.
63
+ return { type: "session.notice", ...context, code: "transfer_required",
64
+ message: "Update the Bivy app to load this large response.",
65
+ transfer: { id, bytes: sealed.length, key: key.toString("hex"), iv: iv.toString("hex"), pageBytes: TRANSFER_PAGE_BYTES } };
66
+ }
67
+ read(command) {
68
+ this.prune();
69
+ const { transferId, offset, requestId } = command;
70
+ const entry = typeof transferId === "string" ? this.entries.get(transferId) : undefined;
71
+ const base = { type: "transfer.part", transferId, requestId, offset };
72
+ if (!entry)
73
+ return { ...base, error: "This response expired. Retry to fetch a fresh snapshot." };
74
+ if (typeof offset !== "number" || !Number.isSafeInteger(offset) || offset < 0 || offset >= entry.body.length || offset % TRANSFER_PAGE_BYTES !== 0) {
75
+ return { ...base, error: "Invalid transfer offset." };
76
+ }
77
+ entry.expires = Math.min(entry.deadline, this.now() + TTL_MS);
78
+ return { ...base, data: entry.body.subarray(offset, offset + TRANSFER_PAGE_BYTES).toString("base64") };
79
+ }
80
+ clear() { clearInterval(this.timer); this.timer = undefined; this.entries.clear(); this.bytes = 0; }
81
+ }
@@ -1,6 +1,7 @@
1
1
  // SPDX-License-Identifier: AGPL-3.0-only
2
2
  // Copyright (c) 2026 Petter André Sjulstad
3
3
  import fs from "node:fs";
4
+ import { EventTransferStore } from "./event-transfer.js";
4
5
  import path from "node:path";
5
6
  import { WebSocket } from "ws";
6
7
  import { seal, sealFrame, openFrame, ReplayGuard } from "../e2e.js";
@@ -94,7 +95,11 @@ export class RelayConnector {
94
95
  // explains why instead of silently showing "configured".
95
96
  lastErrorMessage;
96
97
  replay = new ReplayGuard();
97
- reassembler = new FrameReassembler();
98
+ transfers = new EventTransferStore();
99
+ reassembler = new FrameReassembler({ onReject: message => {
100
+ console.warn(`[relay] ${message}`);
101
+ this.sendEvent({ type: "session.error", code: "delivery_failed", error: message });
102
+ } });
98
103
  pairing;
99
104
  onWorkAvailable;
100
105
  constructor(config, onClientMessage, options) {
@@ -135,6 +140,8 @@ export class RelayConnector {
135
140
  void this.connect();
136
141
  }
137
142
  stop() {
143
+ this.transfers.clear();
144
+ this.reassembler.reset();
138
145
  this.options.previews?.disconnect();
139
146
  this.closed = true;
140
147
  this.ready = false;
@@ -204,7 +211,8 @@ export class RelayConnector {
204
211
  sendEvent(event) {
205
212
  if (this.ws?.readyState !== WebSocket.OPEN)
206
213
  return;
207
- const payload = sealFrame(this.roomKey(), event);
214
+ const offered = event?.type === "transfer.part" ? null : this.transfers.offer(event);
215
+ const payload = sealFrame(this.roomKey(), offered ?? event);
208
216
  // Large events (big file reads, long diffs, image attachments) are split
209
217
  // into multiple relay frames so none exceeds the relay's max-frame limit;
210
218
  // the client reassembles them before decrypting.
@@ -395,7 +403,11 @@ export class RelayConnector {
395
403
  console.warn("[relay] dropped stale or replayed client frame");
396
404
  return;
397
405
  }
398
- this.onClientMessage(frame.data);
406
+ const command = frame.data;
407
+ if (command.kind === "transfer.read")
408
+ this.sendEvent(this.transfers.read(command));
409
+ else
410
+ this.onClientMessage(command);
399
411
  }
400
412
  catch {
401
413
  console.warn("[relay] failed to decrypt client frame");
@@ -424,6 +436,7 @@ export class RelayConnector {
424
436
  ws.on("close", () => {
425
437
  if (this.ws !== ws)
426
438
  return;
439
+ this.reassembler.reset();
427
440
  this.options.previews?.disconnect();
428
441
  this.ready = false;
429
442
  this.remoteClients = 0;
package/dist/server.js CHANGED
@@ -55,6 +55,7 @@ import { createSessionNamer, fallbackSessionName } from "./session/session-namer
55
55
  import { createBranchPublish } from "./session/branch-publish.js";
56
56
  import { createForkStandUp } from "./session/fork-standup.js";
57
57
  import { createForkRetire } from "./session/fork-retire.js";
58
+ import { createClientProjection } from "./session/client-projection.js";
58
59
  import { createTranscriptPersistence } from "./session/transcript-persistence.js";
59
60
  import { createRunTerminals } from "./session/run-terminal.js";
60
61
  import { createRunLogStore } from "./session/run-log-store.js";
@@ -143,6 +144,7 @@ import { SecretVault, resolveSecret } from "./secrets.js";
143
144
  import { deviceFlowClientId, requestDeviceCode, pollAccessTokenOnce, REPO_CONNECT_SCOPE } from "./github-device-auth.js";
144
145
  import { InstallationTokenCache, createAppJwt, resolveInstallationId } from "./github-app-auth.js";
145
146
  import { listGithubAppRepos } from "./github-app-repos.js";
147
+ import { discoverGithubRepos, listGithubUserRepos } from "./github-repos.js";
146
148
  import { loadGitHubAppConfigs, orderAppsForOwner, listGitHubApps, removeGitHubApp, upsertGitHubApp, privateKeyIdFor, } from "./github-apps.js";
147
149
  import { buildAppManifest, convertManifest, renderManifestForm } from "./github-app-manifest.js";
148
150
  import { encryptGithubAppEnvelope, decryptGithubAppEnvelope, readLocalGithubAppVaultKey, writeLocalGithubAppVaultKey, forgetLocalGithubAppVaultKey, mintLocalGithubAppVaultKey, } from "./github-app-vault.js";
@@ -1450,7 +1452,11 @@ const commands = [
1450
1452
  // A local client whose send buffer has grown past this is behind on reads (slow
1451
1453
  // network, background tab); used by both broadcast paths below.
1452
1454
  const CLIENT_BACKPRESSURE_BYTES = 8 * 1024 * 1024;
1455
+ let projectClientMessage;
1453
1456
  function broadcast(payload) {
1457
+ const p = payload;
1458
+ if (p?.type === "session.event" && p.sessionId && projectClientMessage)
1459
+ payload = projectClientMessage(p.sessionId, payload);
1454
1460
  const data = JSON.stringify(payload);
1455
1461
  for (const client of clients) {
1456
1462
  if (client.readyState !== WebSocket.OPEN)
@@ -1481,6 +1487,9 @@ function broadcast(payload) {
1481
1487
  // out as broadcast(), but skips any local client that is backed up: dropping a
1482
1488
  // superseded update is lossless because a newer one always follows.
1483
1489
  function broadcastCoalesced(payload) {
1490
+ const p = payload;
1491
+ if (p?.type === "session.event" && p.sessionId && projectClientMessage)
1492
+ payload = projectClientMessage(p.sessionId, payload);
1484
1493
  const data = JSON.stringify(payload);
1485
1494
  for (const client of clients) {
1486
1495
  if (client.readyState !== WebSocket.OPEN)
@@ -1630,6 +1639,7 @@ const attachmentStore = new AttachmentStore(path.join(appDir, "attachments"), {
1630
1639
  maxStoreBytes: positiveEnvNumber("BIVY_ATTACHMENT_STORE_MAX_BYTES", 2 * 1024 * 1024 * 1024),
1631
1640
  retentionMs: positiveEnvNumber("BIVY_ATTACHMENT_RETENTION_MS", 30 * 24 * 60 * 60 * 1000),
1632
1641
  });
1642
+ projectClientMessage = createClientProjection(attachmentStore, eventLog);
1633
1643
  let attachmentGcStats = attachmentStore.stats();
1634
1644
  function referencedAttachmentHashes() {
1635
1645
  // If transcript history is unreadable, collecting nothing would make its
@@ -10483,46 +10493,25 @@ async function createControlPlaneHook(kind = "github") {
10483
10493
  throw new Error(data?.error || `Could not create Bivy webhook (${res.status})`);
10484
10494
  return data;
10485
10495
  }
10486
- // List the GitHub repos the node can reach with its own token (for picking a
10487
- // git workspace). Uses local App installations when available, otherwise the
10488
- // token stored on this machine (env or `gh`). Returns `authed: false` with
10489
- // an empty list when there's no token so the UI falls back to manual owner/repo.
10496
+ // Combine local App access with the configured user token or gh login, matching
10497
+ // the credentials available to clone/fetch. Cache only complete, successful lists.
10490
10498
  async function listAccessibleRepos() {
10491
10499
  if (reposCache && Date.now() - reposCache.at < REPO_LIST_TTL_MS)
10492
10500
  return reposCache.val;
10493
- try {
10494
- const apps = await ensureGitHubApps();
10495
- if (apps.length) {
10496
- const val = { authed: true, repos: await listGithubAppRepos(apps) };
10497
- reposCache = { at: Date.now(), val };
10498
- return val;
10499
- }
10500
- const token = await resolveGitHubToken();
10501
- if (!token)
10502
- return { authed: false, repos: [], reason: (await ghCliInstalled()) ? "gh-unauthed" : "no-token" };
10503
- const ghRes = await fetch("https://api.github.com/user/repos?sort=updated&per_page=100&affiliation=owner,collaborator,organization_member", {
10504
- headers: { authorization: `Bearer ${token}`, accept: "application/vnd.github+json", "user-agent": "bivy" },
10505
- });
10506
- if (!ghRes.ok)
10507
- return { authed: true, repos: [], error: `GitHub responded ${ghRes.status}` };
10508
- const raw = (await ghRes.json().catch(() => []));
10509
- const repos = (Array.isArray(raw) ? raw : []).map((r) => ({
10510
- slug: String(r.full_name ?? ""),
10511
- description: typeof r.description === "string" ? r.description : "",
10512
- private: Boolean(r.private),
10513
- pushedAt: typeof r.pushed_at === "string" ? r.pushed_at : undefined,
10514
- // GitHub already hands us each repo's default branch here, so the branch
10515
- // picker can label "Default branch (main)" instantly from the cached repo
10516
- // list — no separate /repos/{owner}/{repo} round trip when it opens.
10517
- defaultBranch: typeof r.default_branch === "string" ? r.default_branch : undefined,
10518
- })).filter((r) => r.slug);
10519
- const val = { authed: true, repos };
10501
+ const val = await discoverGithubRepos({
10502
+ appRepos: async () => {
10503
+ const apps = await ensureGitHubApps();
10504
+ return apps.length ? listGithubAppRepos(apps) : undefined;
10505
+ },
10506
+ userRepos: async () => {
10507
+ const token = await resolveGitHubToken();
10508
+ return token ? listGithubUserRepos(token) : undefined;
10509
+ },
10510
+ ghInstalled: ghCliInstalled,
10511
+ });
10512
+ if (val.authed && !val.error)
10520
10513
  reposCache = { at: Date.now(), val };
10521
- return val;
10522
- }
10523
- catch (error) {
10524
- return { authed: false, repos: [], error: error instanceof Error ? error.message : String(error) };
10525
- }
10514
+ return val;
10526
10515
  }
10527
10516
  let pendingGithubConnect = null;
10528
10517
  async function startGithubConnect() {
@@ -0,0 +1,71 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ import { createHash } from "node:crypto";
4
+ /** A display-only projection. Never edit runtime messages or persisted bases:
5
+ * the model still needs its native image blocks when a session resumes. */
6
+ export function createClientProjection(store, log) {
7
+ const refs = new Map();
8
+ return (sessionId, value) => {
9
+ let retained;
10
+ let changed = false;
11
+ function visit(value) {
12
+ if (!value || typeof value !== "object")
13
+ return value;
14
+ if (Array.isArray(value))
15
+ return value.map(visit);
16
+ const block = value;
17
+ const source = block.source;
18
+ let data;
19
+ let mime;
20
+ if (block.type === "image") {
21
+ data = block.data ?? (source?.type === "base64" ? source.data : undefined);
22
+ mime = block.mimeType ?? source?.media_type;
23
+ }
24
+ else if (block.type === "image_url" || block.type === "input_image") {
25
+ const url = typeof block.image_url === "string" ? block.image_url : block.image_url?.url;
26
+ if (typeof url === "string") {
27
+ const match = /^data:(image\/[a-zA-Z0-9.+-]+);base64,([\s\S]*)$/.exec(url);
28
+ if (match) {
29
+ mime = match[1];
30
+ data = match[2];
31
+ }
32
+ }
33
+ }
34
+ if (typeof data === "string") {
35
+ try {
36
+ if (data.length > 36 * 1024 * 1024 || !/^[A-Za-z0-9+/]*={0,2}$/.test(data) || data.length % 4 === 1)
37
+ throw new Error("Invalid image encoding");
38
+ const mimeType = typeof mime === "string" && /^image\/[a-zA-Z0-9.+-]+$/.test(mime) ? mime : "image/png";
39
+ const cacheKey = createHash("sha256").update(mimeType).update(data).digest("hex");
40
+ let ref = refs.get(cacheKey);
41
+ if (!ref || !store.getPath(ref.hash)) {
42
+ ref = store.put(Buffer.from(data, "base64"), { name: `image.${mimeType.split("/")[1]}`, mimeType, kind: "image" });
43
+ if (refs.size >= 1024)
44
+ refs.delete(refs.keys().next().value);
45
+ refs.set(cacheKey, ref);
46
+ }
47
+ const url = `bivy-embedded:${ref.hash}`;
48
+ retained ??= new Set(log.readInlineImages(sessionId).map(([url]) => url));
49
+ if (!retained.has(url)) {
50
+ log.appendInlineImage(sessionId, { url, ref });
51
+ retained.add(url);
52
+ changed = true;
53
+ }
54
+ return { type: "bivy_attachment", ref };
55
+ }
56
+ catch {
57
+ // Failure is visible in the display, while original data remains in
58
+ // the native transcript. Never fall back to sending the huge bytes.
59
+ return { type: "text", text: "[Image unavailable: could not store this image for display. The original is retained on the machine.]" };
60
+ }
61
+ }
62
+ // Only recognized image blocks are rewritten. Arbitrary strings and tool
63
+ // arguments remain byte-for-byte intact, including large textual results.
64
+ return Object.fromEntries(Object.entries(block).map(([key, child]) => [key, visit(child)]));
65
+ }
66
+ const projected = visit(value);
67
+ if (changed)
68
+ log.flush(sessionId);
69
+ return projected;
70
+ };
71
+ }
@@ -1,5 +1,6 @@
1
1
  // SPDX-License-Identifier: AGPL-3.0-only
2
2
  // Copyright (c) 2026 Petter André Sjulstad
3
+ import { EventTransferReceiver } from "../remote/event-transfer-receiver.js";
3
4
  //
4
5
  // A NODE acting as a relay CLIENT of a sibling node it co-owns — the transport
5
6
  // that lets an owner daemon stream replication frames to its standby
@@ -30,7 +31,12 @@ export class SiblingClient {
30
31
  ws;
31
32
  cipher;
32
33
  keypair = newDeviceKeypair();
33
- reassembler = new FrameReassembler();
34
+ reassembler = new FrameReassembler({ onReject: () => this.ws?.close() });
35
+ transfers = new EventTransferReceiver({
36
+ send: async (command) => this.sendSealed(command),
37
+ emit: event => this.dispatchEvent(event),
38
+ fault: () => this.ws?.close(),
39
+ });
34
40
  pending = new Map();
35
41
  paired;
36
42
  resolvePaired;
@@ -101,7 +107,7 @@ export class SiblingClient {
101
107
  return;
102
108
  try {
103
109
  const frame = this.cipher.open(full);
104
- this.dispatchEvent(frame.data);
110
+ this.transfers.accept(frame.data);
105
111
  }
106
112
  catch {
107
113
  /* undecryptable frame — ignore */
@@ -179,6 +185,8 @@ export class SiblingClient {
179
185
  return (await res.json());
180
186
  }
181
187
  handleClose() {
188
+ this.transfers.reset();
189
+ this.reassembler.reset();
182
190
  if (this.closed)
183
191
  return;
184
192
  for (const [, waiter] of this.pending) {
@@ -190,6 +198,8 @@ export class SiblingClient {
190
198
  this.opts.onClose?.();
191
199
  }
192
200
  close() {
201
+ this.transfers.reset();
202
+ this.reassembler.reset();
193
203
  this.closed = true;
194
204
  try {
195
205
  this.ws?.close();
@@ -13,6 +13,7 @@
13
13
  // so its logic is unit-testable without a live daemon. The EventLog /
14
14
  // AttachmentStore singletons stay server-owned (GC, delete, replication use them)
15
15
  // and are injected.
16
+ import { createClientProjection } from "./client-projection.js";
16
17
  import { randomBytes } from "node:crypto";
17
18
  import { mergeBases } from "./event-log.js";
18
19
  import { thinkingTextFromContent } from "./transcript-merge.js";
@@ -72,6 +73,7 @@ function thinkingTextFromEvent(event) {
72
73
  const INLINE_IMAGE_RETRY_COOLDOWN_MS = 10 * 60 * 1000;
73
74
  export function createTranscriptPersistence(deps) {
74
75
  const { eventLog, attachmentStore } = deps;
76
+ const project = createClientProjection(attachmentStore, eventLog);
75
77
  const liveIntermediateBySession = new Map();
76
78
  const lastPersistedIntermediateText = new Map();
77
79
  // In-flight dedupe + failure cooldown so a repeated remote image URL only ever
@@ -204,7 +206,8 @@ export function createTranscriptPersistence(deps) {
204
206
  return eventLog.deriveHistory(record.id, record.session.getMessages());
205
207
  }
206
208
  function buildHistoryEvent(opts) {
207
- const delta = historyDelta(opts.messages, opts.cursor);
209
+ const messages = opts.sessionId ? project(opts.sessionId, opts.messages) : opts.messages;
210
+ const delta = historyDelta(messages, opts.cursor);
208
211
  const record = opts.sessionId ? deps.getOpenSession(opts.sessionId) : undefined;
209
212
  const bSess = record ? deps.bivySessionEnvelope(record) : undefined;
210
213
  return {
@@ -244,7 +247,7 @@ export function createTranscriptPersistence(deps) {
244
247
  epoch: deps.streamEpoch,
245
248
  mode: outcome.mode,
246
249
  head: outcome.head,
247
- events: outcome.mode === "replay" ? outcome.events : [],
250
+ events: outcome.mode === "replay" ? project(sessionId, outcome.events) : [],
248
251
  };
249
252
  }
250
253
  return {
@@ -69,3 +69,6 @@ export const FRAME_CHUNK_BYTES = 192 * 1024;
69
69
  export const MAX_REASSEMBLY_BYTES = 32 * 1024 * 1024;
70
70
  export const MAX_FRAME_CHUNKS = 4096;
71
71
  export const MAX_REASSEMBLY_GROUPS = 16;
72
+ /** Independently sealed large-response pages and maximum logical JSON size. */
73
+ export const TRANSFER_PAGE_BYTES = 384 * 1024;
74
+ export const MAX_TRANSFER_BYTES = 64 * 1024 * 1024;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bivy/bivy",
3
- "version": "0.18.5-staging.5",
3
+ "version": "0.18.5-staging.7",
4
4
  "type": "module",
5
5
  "license": "AGPL-3.0-only",
6
6
  "description": "Run coding agents on machines you own. Open-source, self-hostable agent workspace.",