@bivy/bivy 0.17.2-staging.1 → 0.17.2-staging.3

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.
@@ -16,6 +16,8 @@ const COOKIE = "__Host-bivy-preview";
16
16
  const OPEN_PATH = "/__bivy/open";
17
17
  const REDEEM_PATH = "/__bivy/redeem";
18
18
  const HOUR = 60 * 60_000;
19
+ /** Copied links are reusable until revoked, capped so a forgotten one lapses. */
20
+ export const SHARE_TTL = 24 * HOUR;
19
21
  const HOP = new Set(["connection", "keep-alive", "proxy-authenticate", "proxy-authorization", "te", "trailer", "transfer-encoding", "upgrade"]);
20
22
  /** Separate per-app origins are mandatory. TLS terminates at the deployment's
21
23
  * preview ingress. Delivery reaches only this gateway, never the node API. */
@@ -85,9 +87,7 @@ export class AppGateway {
85
87
  origin(id) { return this.template.replace("{app}", id); }
86
88
  shellOrigin(id) { return this.template.replace("{app}", `view-${id}`); }
87
89
  open(id, returnTo) {
88
- const entry = this.registry.getView(id);
89
- if (entry?.view.kind !== "web")
90
- throw new Error("Web view not found.");
90
+ const entry = this.requireWeb(id);
91
91
  if (returnTo) {
92
92
  const url = new URL(returnTo);
93
93
  const safeScheme = url.protocol === "https:" || (url.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname));
@@ -97,13 +97,15 @@ export class AppGateway {
97
97
  throw new Error("Invalid return-to-chat URL.");
98
98
  returnTo = url.href;
99
99
  }
100
- this.sweep();
101
- if (this.tickets.size >= 500 || this.sessions.size >= 500)
102
- throw new Error("Too many preview grants. Try again later.");
103
- const ticket = randomBytes(32).toString("hex");
104
- this.tickets.set(ticket, { appId: id, expires: Date.now() + 60_000, returnTo });
105
100
  // Fragment never reaches reverse-proxy access logs or the application.
106
- return `${this.shellOrigin(id)}${OPEN_PATH}#${ticket}`;
101
+ return `${this.shellOrigin(id)}${OPEN_PATH}#${this.grant({ appId: id, expires: Date.now() + 60_000, returnTo })}`;
102
+ }
103
+ /** A reusable link straight to the app origin (no shell frame), so it opens
104
+ * in any browser and can be inspected. Valid until revoked or SHARE_TTL. */
105
+ share(id) {
106
+ this.requireWeb(id);
107
+ const expiresAt = Date.now() + SHARE_TTL;
108
+ return { url: `${this.origin(id)}${OPEN_PATH}#${this.grant({ appId: id, expires: expiresAt, reusable: true })}`, expiresAt };
107
109
  }
108
110
  revoke(id) {
109
111
  for (const map of [this.tickets, this.sessions])
@@ -122,6 +124,20 @@ export class AppGateway {
122
124
  this.server.close();
123
125
  this.server.closeAllConnections();
124
126
  }
127
+ requireWeb(id) {
128
+ const entry = this.registry.getView(id);
129
+ if (entry?.view.kind !== "web")
130
+ throw new Error("Web view not found.");
131
+ return entry;
132
+ }
133
+ grant(grant) {
134
+ this.sweep();
135
+ if (this.tickets.size >= 500 || this.sessions.size >= 500)
136
+ throw new Error("Too many preview grants. Try again later.");
137
+ const ticket = randomBytes(32).toString("hex");
138
+ this.tickets.set(ticket, grant);
139
+ return ticket;
140
+ }
125
141
  sweep() {
126
142
  for (const map of [this.tickets, this.sessions])
127
143
  for (const [key, grant] of map)
@@ -247,10 +263,13 @@ export class AppGateway {
247
263
  res.end();
248
264
  return;
249
265
  }
250
- this.tickets.delete(body);
266
+ if (!grant.reusable)
267
+ this.tickets.delete(body);
268
+ // A browser session never outlives the link that created it.
269
+ const expires = Math.min(Date.now() + HOUR, grant.expires);
251
270
  const token = randomBytes(32).toString("hex");
252
- this.sessions.set(token, { appId: id, expires: Date.now() + HOUR });
253
- res.setHeader("Set-Cookie", `${COOKIE}=${token}; Secure; HttpOnly; SameSite=Lax; Path=/; Max-Age=3600`);
271
+ this.sessions.set(token, { appId: id, expires });
272
+ res.setHeader("Set-Cookie", `${COOKIE}=${token}; Secure; HttpOnly; SameSite=Lax; Path=/; Max-Age=${Math.max(1, Math.floor((expires - Date.now()) / 1000))}`);
254
273
  res.writeHead(204);
255
274
  res.end();
256
275
  return;
@@ -59,6 +59,11 @@ export class RemotePreview {
59
59
  throw new Error("Preview delivery is unavailable. Reconnect the machine and try again.");
60
60
  return this.gateway.open(id, returnTo);
61
61
  }
62
+ share(id) {
63
+ if (!this.available)
64
+ throw new Error("Preview delivery is unavailable. Reconnect the machine and try again.");
65
+ return this.gateway.share(id);
66
+ }
62
67
  revoke(id) { this.gateway?.revoke(id); }
63
68
  disconnect() {
64
69
  this.online = false;
@@ -48,6 +48,19 @@ export class AppService {
48
48
  throw error;
49
49
  }
50
50
  }
51
+ share(sessionId, appId, viewId) {
52
+ if (this.registry.requireView(sessionId, appId, viewId).view.kind !== "web")
53
+ throw new Error("Only web views have preview links.");
54
+ if (!this.gateway)
55
+ throw new Error("Bivy's preview service is unavailable on this connection.");
56
+ return this.gateway.share(viewId);
57
+ }
58
+ /** Ends every link, browser session and open connection for one view; the app stays. */
59
+ revoke(sessionId, appId, viewId) {
60
+ this.registry.requireView(sessionId, appId, viewId);
61
+ this.gateway?.revoke(viewId);
62
+ return { ok: true };
63
+ }
51
64
  remove(sessionId, appId) {
52
65
  const app = this.registry.require(sessionId, appId);
53
66
  this.registry.remove(sessionId, appId);
@@ -2954,7 +2954,9 @@ select.picker-search { appearance: auto; }
2954
2954
  .app-views-heading { display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); }
2955
2955
  .app-views-heading .artifact-name, .app-message .artifact-name { white-space: normal; overflow-wrap: anywhere; }
2956
2956
  .app-view-row { flex-wrap: wrap; }
2957
- .app-view-row > .btn, .app-views-heading > .btn { min-block-size: var(--space-7); flex-shrink: 0; }
2957
+ .app-views-heading > .btn, .app-view-actions .btn { min-block-size: var(--space-7); flex-shrink: 0; }
2958
+ .app-view-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: var(--space-2); margin-inline-start: auto; }
2959
+ .app-view-notice { display: flex; flex-direction: column; gap: var(--space-2); flex-basis: 100%; min-width: 0; }
2958
2960
  .app-terminal-status { padding: var(--space-2) var(--space-4); margin: 0; color: var(--muted); font-size: var(--text-sm); }
2959
2961
  .app-view-command { overflow-wrap: anywhere; white-space: pre-wrap; font-size: var(--text-xs); color: var(--muted); }
2960
2962
  .app-view-row .artifact-main { flex-basis: 60%; }
@@ -11,6 +11,8 @@ export function createAppCommands(service, workspaceFor, published = () => { })
11
11
  return { app };
12
12
  },
13
13
  "apps.open": (msg) => service.open(String(msg.sessionId), String(msg.appId), String(msg.viewId), typeof msg.returnTo === "string" ? msg.returnTo : undefined),
14
+ "apps.share": (msg) => service.share(String(msg.sessionId), String(msg.appId), String(msg.viewId)),
15
+ "apps.revoke": (msg) => service.revoke(String(msg.sessionId), String(msg.appId), String(msg.viewId)),
14
16
  "apps.remove": (msg) => { service.remove(String(msg.sessionId), String(msg.appId)); return { ok: true }; },
15
17
  };
16
18
  return Object.fromEntries(Object.entries(operations).map(([kind, execute]) => [kind, async (msg, ctx) => {
@@ -3,7 +3,7 @@
3
3
  /** Direct-mode adapters for operations whose canonical implementation is the
4
4
  * client command registry. Relay and HTTP now differ only in framing. */
5
5
  export const CLIENT_COMMAND_ROUTES = [
6
- ...["list", "publish", "open", "remove"].map((action) => ({ method: "post", path: `/api/apps/${action}`, kind: `apps.${action}` })),
6
+ ...["list", "publish", "open", "share", "revoke", "remove"].map((action) => ({ method: "post", path: `/api/apps/${action}`, kind: `apps.${action}` })),
7
7
  { method: "post", path: "/api/auth/credentials/native-preview", kind: "credentials.native.preview" },
8
8
  { method: "post", path: "/api/auth/credentials/native-import", kind: "credentials.native.import" },
9
9
  { method: "post", path: "/api/session/pause", kind: "session.pause" },
@@ -11,6 +11,8 @@ export const CLIENT_COMMAND_SCHEMAS = {
11
11
  "apps.list": Type.Object({ ...request, ...session }),
12
12
  "apps.publish": Type.Object({ ...request, ...session, manifest: Type.Unknown() }),
13
13
  "apps.open": Type.Object({ ...request, ...session, appId: Type.String(), viewId: Type.String(), returnTo: Type.Optional(Type.String({ maxLength: 2048 })) }),
14
+ "apps.share": Type.Object({ ...request, ...session, appId: Type.String(), viewId: Type.String() }),
15
+ "apps.revoke": Type.Object({ ...request, ...session, appId: Type.String(), viewId: Type.String() }),
14
16
  "apps.remove": Type.Object({ ...request, ...session, appId: Type.String() }),
15
17
  "credentials.native.preview": Type.Object({ ...request, label: Type.Optional(Type.String({ maxLength: 100 })) }),
16
18
  "credentials.native.import": Type.Object({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bivy/bivy",
3
- "version": "0.17.2-staging.1",
3
+ "version": "0.17.2-staging.3",
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.",