@bivy/bivy 0.16.24 → 0.16.25-staging.2

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.
@@ -43,6 +43,7 @@ export function claudeCodeIntegration(origin) {
43
43
  testedVersion: CLAUDE_TESTED_VERSION,
44
44
  source: origin,
45
45
  authOwner: "agent",
46
+ credentialRequirements: { owner: "agent", strategy: "agent-login", providers: ["anthropic"] },
46
47
  notes: installed
47
48
  ? "Uses the Claude Code executable already on this node, including its native auth, configuration, and sessions. Set BIVY_CLAUDE_MODEL to pick a default model."
48
49
  : agentInstalled
@@ -157,6 +157,7 @@ export function codexIntegration(origin) {
157
157
  testedVersion: CODEX_TESTED_VERSION,
158
158
  source: origin,
159
159
  authOwner: "agent",
160
+ credentialRequirements: { owner: "agent", strategy: "agent-login", providers: ["openai-codex", "openai"] },
160
161
  notes: installed
161
162
  ? "Uses Codex's native app-server, login, configuration, rollouts, model selection, and sandbox while Bivy mediates structured tool approvals."
162
163
  : "Install and sign in to Codex on this node; Bivy will connect to that existing agent.",
@@ -125,6 +125,7 @@ export function piIntegration(origin) {
125
125
  testedVersion: PI_TESTED_VERSION,
126
126
  source: origin,
127
127
  authOwner: "agent",
128
+ credentialRequirements: { owner: "agent", strategy: "one-of", providers: [] },
128
129
  notes: !nodeSupportsPi()
129
130
  ? unsupportedNodeMessage()
130
131
  : installed
@@ -122,13 +122,20 @@ export function createCredentialCommands(deps) {
122
122
  }
123
123
  },
124
124
  async "credential.unattended.set"(msg, ctx) {
125
+ const provider = String(msg.provider ?? "");
126
+ const label = String(msg.label ?? "");
127
+ const previous = (await listCredentialRecords(credsDir)).find((record) => record.provider === provider.trim().toLowerCase() && record.label === label)?.unattended === true;
125
128
  try {
126
- await setCredentialUnattended(credsDir, String(msg.provider ?? ""), String(msg.label ?? ""), msg.unattended === true);
129
+ await setCredentialUnattended(credsDir, provider, label, msg.unattended === true);
127
130
  await deps.pushModelAuthToControlPlane();
128
131
  deps.sendEvent({ type: "credentials.records", records: await listCredentialRecords(credsDir) });
129
132
  ctx.reply({ type: "credential.unattended.set.ok", requestId: msg.requestId });
130
133
  }
131
134
  catch (error) {
135
+ // The UI must never claim an encrypted Cloud copy exists when custody
136
+ // publication failed. Restore the prior local grant before rejecting.
137
+ await setCredentialUnattended(credsDir, provider, label, previous).catch(() => { });
138
+ deps.sendEvent({ type: "credentials.records", records: await listCredentialRecords(credsDir) });
132
139
  const message = error instanceof Error ? error.message : String(error);
133
140
  ctx.reply({ type: "credential.unattended.set.error", requestId: msg.requestId, error: message });
134
141
  }
@@ -87,17 +87,44 @@ function sameRecordContent(a, b) {
87
87
  const strip = ({ updatedAt: _drop, ...rest }) => rest;
88
88
  return JSON.stringify(strip(a)) === JSON.stringify(strip(b));
89
89
  }
90
+ function canonicalCredential(credential) {
91
+ const { updatedAt: _drop, ...content } = credential;
92
+ const stable = (value) => {
93
+ if (Array.isArray(value))
94
+ return value.map(stable);
95
+ if (!value || typeof value !== "object")
96
+ return value;
97
+ return Object.fromEntries(Object.entries(value)
98
+ .sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)
99
+ .map(([key, child]) => [key, stable(child)]));
100
+ };
101
+ return JSON.stringify(stable(content));
102
+ }
90
103
  /**
91
- * Should `incoming` replace `local` during a non-destructive merge? The v2 rule,
92
- * re-keyed to records:
93
- * - No local take incoming.
94
- * - Only stored-OAuth-vs-stored-OAuth needs freshness arbitration; anything else
95
- * (api-key, reference, a type/source switch) lets a real content change win.
96
- * - A snapshot with a blank refresh token must never clobber a usable one
97
- * (rotated refresh tokens are single-use).
98
- * - Prefer the token minted LATER by `refreshedAt`; else the later `expires`. A
99
- * tie KEEPS local (strictly-greater wins), so equal stamps can't churn/rotate.
104
+ * OAuth ordering shared by record and legacy-wire merges. This must be a total,
105
+ * symmetric order: OpenAI/Codex rotates refresh tokens, so node A may publish r2
106
+ * after refreshing while node B later uploads its stale r1 snapshot. `updatedAt`
107
+ * on B can be later merely because it synced later; it must not beat the actual
108
+ * token mint time. A usable refresh token wins over a blank one, then the newer
109
+ * `refreshedAt` wins (falling back to access-token `expires` for old records).
110
+ * Equal freshness uses canonical content only to make both merge directions
111
+ * converge; it does not pretend that store write time is token freshness.
100
112
  */
113
+ export function preferIncomingOAuthCredential(local, incoming) {
114
+ const localHasRefresh = Boolean(String(local.refresh ?? "").trim());
115
+ const incomingHasRefresh = Boolean(String(incoming.refresh ?? "").trim());
116
+ if (localHasRefresh !== incomingHasRefresh)
117
+ return incomingHasRefresh;
118
+ const localRefreshed = Number(local.refreshedAt);
119
+ const incomingRefreshed = Number(incoming.refreshedAt);
120
+ const bothHaveRefreshTime = Number.isFinite(localRefreshed) && Number.isFinite(incomingRefreshed);
121
+ const localFreshness = bothHaveRefreshTime ? localRefreshed : (Number(local.expires) || 0);
122
+ const incomingFreshness = bothHaveRefreshTime ? incomingRefreshed : (Number(incoming.expires) || 0);
123
+ if (incomingFreshness !== localFreshness)
124
+ return incomingFreshness > localFreshness;
125
+ return canonicalCredential(incoming) > canonicalCredential(local);
126
+ }
127
+ /** Should `incoming` replace `local` during a non-destructive merge? */
101
128
  export function preferIncomingRecord(local, incoming) {
102
129
  if (!local)
103
130
  return true;
@@ -105,15 +132,7 @@ export function preferIncomingRecord(local, incoming) {
105
132
  const incomingOauth = oauthOf(incoming);
106
133
  if (!localOauth || !incomingOauth)
107
134
  return true;
108
- const localRefresh = String(localOauth.refresh ?? "").trim();
109
- const incomingRefresh = String(incomingOauth.refresh ?? "").trim();
110
- if (!incomingRefresh && localRefresh)
111
- return false;
112
- const lt = Number(localOauth.refreshedAt);
113
- const it = Number(incomingOauth.refreshedAt);
114
- if (Number.isFinite(lt) && Number.isFinite(it))
115
- return it > lt;
116
- return (Number(incomingOauth.expires) || 0) > (Number(localOauth.expires) || 0);
135
+ return preferIncomingOAuthCredential(localOauth, incomingOauth);
117
136
  }
118
137
  /** A tombstone wins only when it is newer than the record it would remove. */
119
138
  export function tombstoneWinsRecord(record, deletedAt) {
@@ -20,7 +20,7 @@ import fsp from "node:fs/promises";
20
20
  import path from "node:path";
21
21
  import { randomBytes } from "node:crypto";
22
22
  import { seal, open } from "../e2e.js";
23
- import { migrateToV3, mergeDocuments, recordFromStored, emptyDocument, } from "./document.js";
23
+ import { migrateToV3, mergeDocuments, preferIncomingOAuthCredential, recordFromStored, emptyDocument, } from "./document.js";
24
24
  import { credKey, parseCredKey, normalizeLabel, inferReferenceBackend, DEFAULT_LABEL } from "./records.js";
25
25
  // Node crypto adapter for the at-rest vault. e2e.ts (AES-256-GCM seal/open) is a
26
26
  // repo crypto leaf; this node service may depend on it. Injecting a Sealer
@@ -61,29 +61,16 @@ function defaultKey(provider) {
61
61
  * - No local entry → take the incoming one.
62
62
  * - Only OAuth-vs-OAuth needs freshness arbitration (an api-key set/replace, or a
63
63
  * type switch, keeps the existing "incoming wins on a real content change").
64
- * - A snapshot that omits the refresh token must never clobber a usable one
65
- * rotated refresh tokens are single-use, so an incoming with a blank refresh is
66
- * strictly worse than a local one that still has it.
67
- * - Prefer the token minted LATER by `refreshedAt` (monotonic mint order) when
68
- * both carry it; otherwise fall back to the access-token `expires`. In both
69
- * cases a tie KEEPS the local credential (strictly-greater wins), so an equal
70
- * stamp can't needlessly churn/rotate the vault, and clock skew can't let an
71
- * equal-`expires` stale token win.
64
+ * - A snapshot that omits the refresh token must never clobber a usable one.
65
+ * - OAuth ordering is delegated to document.ts so record-shaped and legacy
66
+ * wire merges use the same deterministic refreshedAt/expires/content order.
72
67
  */
73
68
  export function preferIncomingCredential(local, incoming) {
74
69
  if (!local)
75
70
  return true;
76
71
  if (local.type !== "oauth" || incoming.type !== "oauth")
77
72
  return true;
78
- const localRefresh = String(local.refresh ?? "").trim();
79
- const incomingRefresh = String(incoming.refresh ?? "").trim();
80
- if (!incomingRefresh && localRefresh)
81
- return false;
82
- const lt = Number(local.refreshedAt);
83
- const it = Number(incoming.refreshedAt);
84
- if (Number.isFinite(lt) && Number.isFinite(it))
85
- return it > lt;
86
- return (Number(incoming.expires) || 0) > (Number(local.expires) || 0);
73
+ return preferIncomingOAuthCredential(local, incoming);
87
74
  }
88
75
  /** A tombstone wins only when it is newer than the credential it would remove. */
89
76
  export function tombstoneWins(credential, deletedAt) {
@@ -87,6 +87,7 @@ export class RelayConnector {
87
87
  // and the socket closing. This — not "a connector object exists" — is what
88
88
  // "connected" means to the control plane, so it's what `bivy status` reports.
89
89
  ready = false;
90
+ remoteClients = 0;
90
91
  // Most recent relay-side failure (ticket mint, socket error, or an `error`
91
92
  // frame), surfaced by `bivy status`/`doctor` so a node that never connects
92
93
  // explains why instead of silently showing "configured".
@@ -120,6 +121,8 @@ export class RelayConnector {
120
121
  get connected() {
121
122
  return this.ready && this.ws?.readyState === WebSocket.OPEN;
122
123
  }
124
+ /** Actual viewers on this relay connection, not a sticky prompt flag. */
125
+ get clientCount() { return this.connected ? this.remoteClients : 0; }
123
126
  /** Most recent relay-side failure, if any — for status/diagnostics. */
124
127
  get lastError() {
125
128
  return this.lastErrorMessage;
@@ -132,6 +135,7 @@ export class RelayConnector {
132
135
  stop() {
133
136
  this.closed = true;
134
137
  this.ready = false;
138
+ this.remoteClients = 0;
135
139
  this.stopHeartbeat();
136
140
  this.clearBackoffReset();
137
141
  this.ws?.close();
@@ -330,6 +334,8 @@ export class RelayConnector {
330
334
  // before declaring the connector usable or resetting reconnect backoff.
331
335
  });
332
336
  ws.on("message", (data) => {
337
+ if (this.ws !== ws)
338
+ return;
333
339
  let env;
334
340
  try {
335
341
  env = JSON.parse(data.toString());
@@ -348,6 +354,13 @@ export class RelayConnector {
348
354
  console.log("[relay] connected");
349
355
  return;
350
356
  }
357
+ if (env.t === "peer.online" || env.t === "peer.offline") {
358
+ if (typeof env.clients === "number" && Number.isSafeInteger(env.clients) && env.clients >= 0)
359
+ this.remoteClients = env.clients;
360
+ else if (env.t === "peer.online")
361
+ this.remoteClients = Math.max(1, this.remoteClients);
362
+ return;
363
+ }
351
364
  if (env.t === "pair" && typeof env.p === "string") {
352
365
  void this.handlePairFrame(env.p);
353
366
  return;
@@ -396,7 +409,10 @@ export class RelayConnector {
396
409
  this.lastPongAt = Date.now();
397
410
  });
398
411
  ws.on("close", () => {
412
+ if (this.ws !== ws)
413
+ return;
399
414
  this.ready = false;
415
+ this.remoteClients = 0;
400
416
  this.stopHeartbeat();
401
417
  this.clearBackoffReset();
402
418
  this.scheduleReconnect();
@@ -361,7 +361,11 @@ export async function cloneOrUpdateRepo(opts) {
361
361
  // that now-unlinked cwd makes git abort before it can even process `clone`
362
362
  // ("Unable to read current working directory"). The repos root is durable
363
363
  // across package updates and was created immediately above.
364
- await exec("git", [...cc, "clone", url, dest], { cwd: opts.root, timeout: 600_000, env });
364
+ // Preserve the full ref/history graph needed by worktrees and PR bases, but
365
+ // defer file blobs until checkout/tooling actually reads them. GitHub supports
366
+ // protocol-v2 partial clones; this removes large historical assets from the
367
+ // managed session's first-message critical path without shallow-history bugs.
368
+ await exec("git", [...cc, "clone", "--filter=blob:none", url, dest], { cwd: opts.root, timeout: 600_000, env });
365
369
  // Persist the helper config so agent-run git in this clone authenticates too.
366
370
  // These follow-up processes need an explicit cwd for the same reason.
367
371
  await configureRepoCredentialHelper((a) => exec("git", a, { cwd: dest }), dest);
@@ -0,0 +1,31 @@
1
+ /** Keep Bivy's durable identity when a portable import receives a new native id.
2
+ * Methods/accessors still run against the native object (including private
3
+ * fields and provider resume ids). No per-agent identity rewriting is needed. */
4
+ export function canonicalSession(session, id) {
5
+ if (!id || session.id === id)
6
+ return session;
7
+ const methods = new Map();
8
+ // A facade target also supports frozen native session objects: a Proxy over
9
+ // the native object cannot override a non-configurable, read-only id.
10
+ return new Proxy(Object.create(Object.getPrototypeOf(session)), {
11
+ get(_target, key) {
12
+ if (key === "id")
13
+ return id;
14
+ const value = Reflect.get(session, key, session);
15
+ if (typeof value !== "function")
16
+ return value;
17
+ if (methods.get(key)?.original !== value)
18
+ methods.set(key, { original: value, bound: value.bind(session) });
19
+ return methods.get(key).bound;
20
+ },
21
+ set: (_target, key, value) => key !== "id" && Reflect.set(session, key, value, session),
22
+ has: (_target, key) => key === "id" || key in session,
23
+ ownKeys: () => [...new Set([...Reflect.ownKeys(session), "id"])],
24
+ getOwnPropertyDescriptor(_target, key) {
25
+ if (key === "id")
26
+ return { value: id, enumerable: true, configurable: true };
27
+ const descriptor = Reflect.getOwnPropertyDescriptor(session, key);
28
+ return descriptor ? { ...descriptor, configurable: true } : undefined;
29
+ },
30
+ });
31
+ }
@@ -18,11 +18,11 @@
18
18
  // `id_token` — the same call Codex itself makes to refresh.
19
19
  //
20
20
  // Rotation note: OpenAI rotates the refresh token on every grant, so we persist
21
- // the rotated token back to the vault. We mint only when no auth.json exists yet
22
- // (Codex then owns and self-refreshes it), which keeps churn to a single grant.
23
- // The residual edge case Codex self-refreshing later invalidates the vault's
24
- // copy for *other* `openai-codex` consumers is documented; for the common case
25
- // (the subscription was connected for Codex) there are no other consumers.
21
+ // the rotated token back to the vault. Codex then owns and self-refreshes its
22
+ // auth.json; terminal-exit ingest and the next launch fold its `last_refresh`
23
+ // token set back into the vault. This repairs persistence but does NOT serialize
24
+ // two machines using copies of one login: operators must still run only one
25
+ // machine (or one serialized job stream) per OpenAI refresh-token lineage.
26
26
  import fs from "node:fs";
27
27
  import os from "node:os";
28
28
  import path from "node:path";
@@ -130,7 +130,10 @@ export async function ensureCodexAuth(credsDir) {
130
130
  const nativeAccess = String(projected.tokens?.access_token ?? "");
131
131
  if (nativeRefresh && Number.isFinite(nativeStamp)) {
132
132
  await store.modify("openai-codex", async (current) => {
133
- if (!current || current.type !== "oauth" || nativeStamp <= Number(current.updatedAt ?? 0))
133
+ if (!current || current.type !== "oauth")
134
+ return current;
135
+ const currentFreshness = Number(current.refreshedAt ?? current.updatedAt ?? 0);
136
+ if (nativeStamp <= currentFreshness)
134
137
  return current;
135
138
  return { ...current, access: nativeAccess || current.access, refresh: nativeRefresh, refreshedAt: nativeStamp };
136
139
  });
@@ -147,15 +150,17 @@ export async function ensureCodexAuth(credsDir) {
147
150
  return undefined;
148
151
  // Persist the rotated refresh token back to the vault FIRST — OpenAI rotates it
149
152
  // on every grant, so the previous one is now dead. If we can't persist it, bail
150
- // rather than strand the vault on a token we've just invalidated.
153
+ // rather than strand the vault on a token we've just invalidated. Use the same
154
+ // stamp in auth.json so later ingestion recognizes this exact token generation.
155
+ const refreshedAt = Date.now();
151
156
  try {
152
157
  await store.modify("openai-codex", async (current) => ({
153
158
  ...(current ?? cred),
154
159
  type: "oauth",
155
160
  access: refreshed.accessToken,
156
161
  refresh: refreshed.refreshToken,
157
- expires: Date.now() + refreshed.expiresIn * 1000,
158
- refreshedAt: Date.now(),
162
+ expires: refreshedAt + refreshed.expiresIn * 1000,
163
+ refreshedAt,
159
164
  }));
160
165
  }
161
166
  catch {
@@ -171,7 +176,7 @@ export async function ensureCodexAuth(credsDir) {
171
176
  refresh_token: refreshed.refreshToken,
172
177
  account_id: accountId,
173
178
  },
174
- last_refresh: new Date().toISOString(),
179
+ last_refresh: new Date(refreshedAt).toISOString(),
175
180
  };
176
181
  try {
177
182
  fs.mkdirSync(codexHome, { recursive: true, mode: 0o700 });
@@ -75,9 +75,20 @@ export function codexAuthToCredential(raw) {
75
75
  return undefined;
76
76
  const accountId = typeof tokens?.account_id === "string" ? tokens.account_id : undefined;
77
77
  const expires = jwtExpiryMs(access) ?? Date.now() + 60 * 60 * 1000;
78
+ // Codex writes last_refresh when it rotates the single-use refresh token.
79
+ // Preserve that mint-order signal: without it, an offline node's stale r1
80
+ // auth.json could be imported after node A's r2 and win merely by syncing last.
81
+ const nativeRefreshTime = Date.parse(String(record.last_refresh ?? ""));
78
82
  return {
79
83
  providerId: "openai-codex",
80
- credential: { type: "oauth", access, refresh, expires, ...(accountId ? { accountId } : {}) },
84
+ credential: {
85
+ type: "oauth",
86
+ access,
87
+ refresh,
88
+ expires,
89
+ ...(Number.isFinite(nativeRefreshTime) ? { refreshedAt: nativeRefreshTime } : {}),
90
+ ...(accountId ? { accountId } : {}),
91
+ },
81
92
  };
82
93
  }
83
94
  /** Fold a login/refresh done in the Codex CLI back into Bivy's vault. Returns imported count. */
@@ -67,7 +67,11 @@ export async function provisionAgentRun(credsDir, piDir, agentId, workspace) {
67
67
  }
68
68
  else if (agentId === "codex") {
69
69
  // Codex reads OPENAI_API_KEY (already in env) or its own auth.json; mint the
70
- // latter from a connected ChatGPT subscription when present.
70
+ // latter from a connected ChatGPT subscription when present. Codex may rotate
71
+ // that file while running; server.ts calls ingestAgentCredentials("codex") on
72
+ // terminal exit, and ensureCodexAuth also reconciles it on the next launch, so
73
+ // refreshed tokens flow back to the vault. This is persistence, not a global
74
+ // lease: never run two machines concurrently from copies of one Codex login.
71
75
  const home = await ensureCodexAuth(credsDir).catch(() => undefined);
72
76
  if (home)
73
77
  env.CODEX_HOME = home;
@@ -2,6 +2,7 @@
2
2
  // Copyright (c) 2026 Petter André Sjulstad
3
3
  import { canonicalAgentId, listRuntimes, makeRuntime } from "./index.js";
4
4
  import { RemoteRuntime, connectSocketTransport } from "./remote.js";
5
+ import { canonicalSession } from "./canonical-session.js";
5
6
  import { withExactCapabilitySurface } from "./types.js";
6
7
  function remoteRuntimeSelection() {
7
8
  const flag = process.env.BIVY_REMOTE_RUNTIME?.trim();
@@ -140,7 +141,8 @@ export class RuntimeHost {
140
141
  return runtime.createSession(options);
141
142
  }
142
143
  async openSession(runtime, options) {
143
- return runtime.openSession(options);
144
+ const result = await runtime.openSession(options);
145
+ return { ...result, session: canonicalSession(result.session, options.canonicalId) };
144
146
  }
145
147
  async listSessions(runtime) {
146
148
  return runtime.listSessions();
@@ -108,6 +108,7 @@ function genericCliInfo() {
108
108
  capabilities: { toolInterception: false, modelSelection: false, resume, packages: false, fork: false },
109
109
  supportTier: "experimental",
110
110
  authOwner: "agent",
111
+ credentialRequirements: { owner: "agent", strategy: "agent-login", providers: [] },
111
112
  notes: configured
112
113
  ? `Configured through BIVY_AGENT_COMMAND / BIVY_AGENT_ARGS / BIVY_AGENT_PROMPT_MODE. Provides universal streaming but not structured approvals unless the agent speaks Bivy protocol.${resume ? " Resumable via BIVY_AGENT_RESUME_TEMPLATE." : " Set BIVY_AGENT_RESUME_TEMPLATE (a JSON arg array with {id}) if the configured agent has its own \"continue session <id>\" flag."}`
113
114
  : "Set BIVY_AGENT_COMMAND to enable this universal CLI runtime.",
@@ -549,6 +550,8 @@ function cliAgentInfo(id, spec) {
549
550
  modelSelection = refined.modelSelection;
550
551
  }
551
552
  }
553
+ const authOwner = spec.authOwner ?? "agent";
554
+ const credentialProviders = [...new Set((spec.model?.models ?? []).flatMap((model) => model.provider ? [model.provider] : []))];
552
555
  return {
553
556
  id,
554
557
  executionMode,
@@ -584,7 +587,12 @@ function cliAgentInfo(id, spec) {
584
587
  nativeSandbox: Boolean(spec.nativeSandbox),
585
588
  supportTier: spec.supportTier ?? "experimental",
586
589
  testedVersion: spec.testedVersion,
587
- authOwner: spec.authOwner ?? "agent",
590
+ authOwner,
591
+ credentialRequirements: {
592
+ owner: authOwner,
593
+ strategy: credentialProviders.length > 0 ? "one-of" : "agent-login",
594
+ providers: credentialProviders,
595
+ },
588
596
  notes: installed
589
597
  ? acpActive
590
598
  // Promoted to ACP: the description must match the governed path actually in
@@ -807,6 +815,7 @@ function acpInfo() {
807
815
  capabilities: { toolInterception: true, modelSelection: false, resume: true, packages: false, fork: false },
808
816
  supportTier: "experimental",
809
817
  authOwner: "agent",
818
+ credentialRequirements: { owner: "agent", strategy: "agent-login", providers: [] },
810
819
  notes: configured
811
820
  ? "Drives an ACP agent via bin/acp-shim.mjs → ProtocolRuntime: Approve/Deny for blocking permission requests, observed activity, streaming transcript, and session/load resume — no per-agent code. Validate against your agent, then promote it into the picker as data."
812
821
  : "Set BIVY_ACP_COMMAND (and optional BIVY_ACP_ARGS, a JSON array) to the ACP agent's launch command, e.g. BIVY_ACP_COMMAND=gemini BIVY_ACP_ARGS='[\"--experimental-acp\"]'.",
@@ -862,6 +871,7 @@ function openClawInfo() {
862
871
  capabilities: { toolInterception: false, modelSelection: false, resume: false, packages: false, fork: false },
863
872
  supportTier: "experimental",
864
873
  authOwner: "agent",
874
+ credentialRequirements: { owner: "agent", strategy: "agent-login", providers: [] },
865
875
  notes: installed
866
876
  ? "Available on PATH. This phase-1 CLI adapter streams stdout/stderr only; Gateway RPC and structured tool approvals require a future OpenClaw protocol bridge. Configure with BIVY_OPENCLAW_COMMAND, BIVY_OPENCLAW_ARGS, and BIVY_OPENCLAW_AGENT."
867
877
  : `${options.command} was not found on PATH. Install OpenClaw on this node, use the PWA install button, or set BIVY_OPENCLAW_COMMAND to its CLI path.`,
@@ -889,6 +899,7 @@ function protocolInfo() {
889
899
  capabilities: { toolInterception: true, modelSelection: true, resume: true, packages: false, fork: false, ...(commands ? { commands } : {}) },
890
900
  supportTier: "experimental",
891
901
  authOwner: "mixed",
902
+ credentialRequirements: { owner: "mixed", strategy: "one-of", providers: [] },
892
903
  notes: configured
893
904
  ? "Configured through BIVY_PROTOCOL_COMMAND / BIVY_PROTOCOL_ARGS. Advertise agent-native slash commands with BIVY_PROTOCOL_COMMANDS (JSON [{name,description}]); other capability flags are finalized by the agent handshake."
894
905
  : "Set BIVY_PROTOCOL_COMMAND to enable a JSONL Bivy Agent Protocol runtime.",
package/dist/server.js CHANGED
@@ -70,6 +70,8 @@ import { authMiddleware, resolveAuth, isAuthorized, requestOriginAllowed } from
70
70
  import { RelayConnector, loadRelayConfig, soloCredentials } from "./remote/index.js";
71
71
  import { readEphemeralTeardownConfig, shouldSelfTeardown, snapshotsDurableForTeardown, performSelfTeardown } from "./ephemeral-teardown.js";
72
72
  import { buildSessionSnapshot, applySessionSnapshot } from "./session/snapshot.js";
73
+ import { clearTurnActivity } from "./session/turn-activity.js";
74
+ import { rebuildSnapshotRuntime } from "./session/snapshot-runtime.js";
73
75
  import { createCheckpointBundle, applyCheckpointBundle, materializeCheckpoint } from "./session/checkpoint-pack.js";
74
76
  import { configuredTurnTimeoutMs, configuredTurnStallMs, configuredTurnActivityStallMs } from "./session/turn-watchdog.js";
75
77
  import { createTurnWatchdog, probeTurnPidAlive } from "./session/turn-watchdog-runtime.js";
@@ -2879,16 +2881,25 @@ const RELAY_COMMANDS = {
2879
2881
  // resolves to afterward.
2880
2882
  const acknowledgeReducedProtections = msg.acknowledgeReducedProtections === true;
2881
2883
  const gateNow = new Date().toISOString();
2882
- const rt = getRuntime(agentFrom(msg) ?? defaultRuntimeId);
2883
- const gateContract = computeSessionContract({ runtime: rt, preview: false, sandbox: sandboxFrom(msg), acknowledgedAt: acknowledgeReducedProtections ? gateNow : undefined }, gateNow);
2884
- if (gateContract.requiresAcknowledgement) {
2885
- relay?.sendEvent({
2886
- type: "session.error",
2887
- code: "reduced_protections_ack_required",
2888
- error: `${rt.displayName || rt.id} would run this session with reduced protections for a certified profile. Confirm to continue.`,
2889
- contract: gateContract,
2890
- requestId,
2891
- });
2884
+ try {
2885
+ const rt = getRuntime(agentFrom(msg) ?? defaultRuntimeId);
2886
+ const gateContract = computeSessionContract({ runtime: rt, preview: false, sandbox: sandboxFrom(msg), acknowledgedAt: acknowledgeReducedProtections ? gateNow : undefined }, gateNow);
2887
+ if (gateContract.requiresAcknowledgement) {
2888
+ relay?.sendEvent({
2889
+ type: "session.error",
2890
+ code: "reduced_protections_ack_required",
2891
+ error: `${rt.displayName || rt.id} would run this session with reduced protections for a certified profile. Confirm to continue.`,
2892
+ contract: gateContract,
2893
+ requestId,
2894
+ });
2895
+ return;
2896
+ }
2897
+ }
2898
+ catch (error) {
2899
+ // Agent availability is checked before workspace creation. Return a real
2900
+ // terminal response to the requesting client instead of letting the relay's
2901
+ // outer console-only catch strand an invisible pending session forever.
2902
+ relay?.sendEvent({ type: "session.error", requestId, error: error instanceof Error ? error.message : String(error) });
2892
2903
  return;
2893
2904
  }
2894
2905
  let record;
@@ -2925,6 +2936,10 @@ const RELAY_COMMANDS = {
2925
2936
  // live-recomputed later, so it can't silently "improve" behind the user.
2926
2937
  record.contract = computeSessionContract({ runtime: getRuntime(record.runtimeId), preview: false, sandbox: record.sandbox, approvalMode: record.approvalMode, acknowledgedAt: acknowledgeReducedProtections ? gateNow : undefined }, gateNow);
2927
2938
  persistSessionMetadata(record);
2939
+ // Session creation returns only after the selected repository/workspace and
2940
+ // runtime are usable. On ephemeral nodes this records a content-free latency
2941
+ // milestone; ordinary personal nodes have no hosted Machine and ignore it.
2942
+ void reportEphemeralMilestone("repositoryReadyAt");
2928
2943
  relay?.sendEvent({
2929
2944
  ...transcripts.buildHistoryEvent({
2930
2945
  sessionId: record.id,
@@ -3050,7 +3065,7 @@ const hostedImportedRecordsPath = path.join(appDir, "model-auth-hosted-records.j
3050
3065
  let lastPushedModelAuthCiphertext = "";
3051
3066
  let lastPushedHostedModelAuthCiphertext = "";
3052
3067
  let lastPushedHostedModelAuthRevision = -1;
3053
- const isHostedCustodyNode = () => Boolean(process.env.BIVY_GITHUB_HOSTED_TASKS);
3068
+ const isHostedCustodyNode = () => Boolean(process.env.BIVY_HOSTED_CREDENTIAL_CUSTODY || process.env.BIVY_GITHUB_HOSTED_TASKS);
3054
3069
  function readLocalModelAuthVaultKey() {
3055
3070
  try {
3056
3071
  const parsed = JSON.parse(fs.readFileSync(modelAuthVaultKeyPath, "utf8"));
@@ -3334,9 +3349,13 @@ async function syncModelAuthFromControlPlane() {
3334
3349
  await pushModelAuthToControlPlane();
3335
3350
  }
3336
3351
  await processModelAuthKeyRequests(data.requests ?? []);
3337
- // Ready means there is no encrypted vault to hydrate, or this node has the
3338
- // key needed to consume it. Ciphertext with no key remains not-ready.
3339
- if (!targetVault?.ciphertext || (hostedCustody ? readHostedModelAuthVaultKey() : readLocalModelAuthVaultKey()))
3352
+ // A personal node with no vault has nothing to hydrate and is ready. A
3353
+ // hosted-custody guest is different: an absent filtered snapshot means it
3354
+ // has no model credential at all, not that hydration succeeded.
3355
+ const credentialsReady = hostedCustody
3356
+ ? Boolean(targetVault?.ciphertext && readHostedModelAuthVaultKey())
3357
+ : Boolean(!targetVault?.ciphertext || readLocalModelAuthVaultKey());
3358
+ if (credentialsReady)
3340
3359
  void reportEphemeralMilestone("credentialsReadyAt");
3341
3360
  }
3342
3361
  catch (error) {
@@ -3359,6 +3378,11 @@ async function processModelAuthKeyRequests(requests) {
3359
3378
  }
3360
3379
  async function pushHostedModelAuthToControlPlane() {
3361
3380
  const [records, revision] = await Promise.all([exportUnattendedRecords(credsDir), unattendedCredentialRevision(credsDir)]);
3381
+ // A setup guest must not establish an empty snapshot when the credential is
3382
+ // first saved (before the explicit grant command follows). Otherwise its
3383
+ // one allowed initial write would be consumed by an unusable vault.
3384
+ if (isHostedCustodyNode() && Object.keys(records).length === 0)
3385
+ return;
3362
3386
  if (revision === lastPushedHostedModelAuthRevision)
3363
3387
  return;
3364
3388
  const key = ensureHostedModelAuthVaultKey();
@@ -3369,7 +3393,7 @@ async function pushHostedModelAuthToControlPlane() {
3369
3393
  });
3370
3394
  const currentResponse = await modelAuthFetch("/node/model-auth-hosted-vault");
3371
3395
  if (currentResponse?.status === 403)
3372
- return; // hosted provisioning is disabled
3396
+ throw new Error("hosted credential custody is not enabled for this account");
3373
3397
  const current = currentResponse?.ok
3374
3398
  ? (await currentResponse.json().catch(() => ({})))
3375
3399
  : {};
@@ -3385,11 +3409,11 @@ async function pushHostedModelAuthToControlPlane() {
3385
3409
  lastPushedHostedModelAuthCiphertext = ciphertext;
3386
3410
  lastPushedHostedModelAuthRevision = revision;
3387
3411
  }
3388
- else if (response?.status !== 403 && response?.status !== 409) {
3412
+ else {
3389
3413
  throw new Error(`hosted model-auth push failed (${response?.status ?? "offline"})`);
3390
3414
  }
3391
3415
  }
3392
- async function pushModelAuthToControlPlane(rotateKey = false) {
3416
+ async function pushModelAuthToControlPlane(rotateKey = false, throwOnFailure = false) {
3393
3417
  if (!sessionAdvertiseTarget)
3394
3418
  return;
3395
3419
  // Piggyback the (plaintext, non-secret) provider status summary on every
@@ -3402,8 +3426,11 @@ async function pushModelAuthToControlPlane(rotateKey = false) {
3402
3426
  // A hosted runner holds only the explicitly granted snapshot and must never
3403
3427
  // overwrite the peer-to-peer account vault with that filtered subset.
3404
3428
  if (isHostedCustodyNode()) {
3405
- // Hosted runners are recipients, never authorities for the custody set.
3406
- // Letting one republish its stale filtered copy could undo a revocation.
3429
+ // A credential-setup guest may establish the initial filtered snapshot.
3430
+ // The control plane refuses managed-guest replacement after that first
3431
+ // write, so normal hosted runners remain recipients rather than authorities.
3432
+ if (process.env.BIVY_HOSTED_CREDENTIAL_PUBLISH === "1" && !lastPushedHostedModelAuthCiphertext)
3433
+ await pushHostedModelAuthToControlPlane();
3407
3434
  return;
3408
3435
  }
3409
3436
  // Only push credentials on the account-sync tier; a `sync: "node"` credential
@@ -3446,6 +3473,8 @@ async function pushModelAuthToControlPlane(rotateKey = false) {
3446
3473
  }
3447
3474
  catch (error) {
3448
3475
  console.warn("[auth-sync] could not push model auth:", error.message);
3476
+ if (throwOnFailure)
3477
+ throw error;
3449
3478
  }
3450
3479
  }
3451
3480
  // --- GitHub App private-key vault sync (issue #88) --------------------------
@@ -6322,6 +6351,7 @@ function persistSessionMetadata(record, status = sessionStatus(record)) {
6322
6351
  delegationDepth: record.delegationDepth,
6323
6352
  runtimeId: record.runtimeId,
6324
6353
  sandbox: record.sandbox,
6354
+ approvalMode: record.approvalMode,
6325
6355
  credentialLabels: record.credentialLabels,
6326
6356
  agentName: getRuntime(record.runtimeId).displayName,
6327
6357
  contract: record.contract,
@@ -6842,18 +6872,23 @@ async function restoreSessionFromSnapshot(sessionId) {
6842
6872
  if (!data.ciphertext)
6843
6873
  return false;
6844
6874
  const applied = await applySessionSnapshot(data.ciphertext, pairingStore.roomKey(), {
6875
+ expectedSessionId: sessionId,
6845
6876
  persistRecords: (id, records) => eventLog.rewrite(id, records),
6846
6877
  applyBundle: async (id, buf) => applyCheckpointBundle(await ensureReplicaRepo(id), id, buf),
6847
6878
  materialize: async (id) => materializeCheckpoint(await ensureReplicaRepo(id), id),
6848
6879
  });
6849
- // Register the rebuilt session so it lists and opens (mirrors the standby's
6850
- // upsertReplicaMeta); the transcript replays from the restored EventLog.
6851
- try {
6852
- metadata.upsertSession({ id: sessionId, source: "restored", status: "saved" });
6853
- }
6854
- catch {
6855
- /* best-effort listing */
6856
- }
6880
+ const info = applied.sessionInfo;
6881
+ if (!info?.runtimeId)
6882
+ throw new Error("Snapshot lacks runtime information; transcript retained but not resumable");
6883
+ const workspace = applied.checkpointCommit ? await ensureReplicaRepo(sessionId) : defaultWorkspace;
6884
+ const rt = await ensureRuntimeAvailable(info.runtimeId, sandboxTier(info.sandbox));
6885
+ const sessionFile = await rebuildSnapshotRuntime(info, rt, eventLog.readBase(sessionId), workspace);
6886
+ // The imported native id may differ; RuntimeHost keeps this durable Bivy id
6887
+ // while letting native methods use their own new resume token.
6888
+ metadata.upsertSession({ id: sessionId, path: sessionFile, runtimeId: info.runtimeId,
6889
+ workspace, name: info.name, sandbox: sandboxTier(info.sandbox), approvalMode: approvalModeFrom(info.approvalMode),
6890
+ credentialLabels: info.credentialLabels,
6891
+ source: "restored", status: "saved" });
6857
6892
  console.log(`[restore] session ${sessionId}: ${applied.recordCount} records, checkpoint ${applied.checkpointCommit ?? "none"}`);
6858
6893
  void reportEphemeralMilestone("snapshotReadyAt");
6859
6894
  return true;
@@ -6884,6 +6919,12 @@ async function flushSessionSnapshots() {
6884
6919
  result.required++;
6885
6920
  try {
6886
6921
  const sealed = await buildSessionSnapshot(record.id, roomKey, {
6922
+ sessionInfo: () => {
6923
+ const model = record.session.getCurrentModel();
6924
+ return { runtimeId: record.runtimeId, name: record.session.getName(),
6925
+ model: model ? { provider: model.provider, id: model.id } : undefined,
6926
+ sandbox: record.sandbox, approvalMode: record.approvalMode, credentialLabels: record.credentialLabels };
6927
+ },
6887
6928
  readRecords: (id) => eventLog.entries(id),
6888
6929
  epochOf: () => 0,
6889
6930
  checkpointHead: async (id) => {
@@ -6926,7 +6967,8 @@ function evaluateEphemeralTeardown() {
6926
6967
  return;
6927
6968
  const records = new Set(openSessions.values());
6928
6969
  const anyWorking = [...records].some((r) => r.isWorking);
6929
- const anyRemoteActive = [...records].some((r) => r.remoteActive);
6970
+ const anyRemoteActive = clients.size > 0 || (relay?.clientCount ?? 0) > 0
6971
+ || [...records].some((r) => r.remoteActive);
6930
6972
  const inFlightWork = controlPlanePoller?.inFlightCount() ?? 0;
6931
6973
  if (anyWorking || anyRemoteActive || inFlightWork > 0) {
6932
6974
  ephemeralEverBusy = true;
@@ -7049,6 +7091,8 @@ function markSessionWorking(record, activity, opts) {
7049
7091
  record.lastFailureAt = undefined;
7050
7092
  metadata.touchSession(record.id, "working");
7051
7093
  if (!wasWorking) {
7094
+ // Do not miss a fast turn that begins and ends between teardown samples.
7095
+ evaluateEphemeralTeardown();
7052
7096
  scheduleAdvertise(); // idle → working transition
7053
7097
  broadcastSessionState(record);
7054
7098
  }
@@ -7057,9 +7101,7 @@ function clearSessionWorking(record, forcedStatus) {
7057
7101
  turnWatchdog.clearTurnAttentionOnProgress(record, true);
7058
7102
  turnWatchdog.clearTurnWatchdog(record);
7059
7103
  touchSession(record);
7060
- record.isWorking = false;
7061
- record.lastActivity = undefined;
7062
- record.workingStartedAt = undefined;
7104
+ clearTurnActivity(record);
7063
7105
  // A completed turn clears any pending manual-resume offer: the session has now
7064
7106
  // moved on (whether it was the resume itself or an unrelated new message).
7065
7107
  metadata.setResumePending(record.id, false);
@@ -7259,7 +7301,9 @@ function actionableAgentError(runtimeId, error) {
7259
7301
  if (id.startsWith("codex"))
7260
7302
  return "Codex is not signed in. Run `codex login`, then retry; the same login works from Bivy and the PWA.";
7261
7303
  if (id === "pi" || id === "aider")
7262
- return "No model credential is configured. Run `bivy provider login`, then retry. This is only required once and compatible credentials sync E2E-encrypted to your other Bivy nodes.";
7304
+ return isHostedCustodyNode()
7305
+ ? "No model credential is available to this Bivy Cloud Machine. Connect a provider and enable it for Bivy Cloud, then retry."
7306
+ : "No model credential is configured. Run `bivy provider login`, then retry. This is only required once and compatible credentials sync E2E-encrypted to your other Bivy nodes.";
7263
7307
  return "The selected agent needs model authentication. Sign in through its native CLI, then retry.";
7264
7308
  }
7265
7309
  return raw;
@@ -7400,6 +7444,7 @@ function attachSessionListeners(record) {
7400
7444
  if (event.type === "turn_start")
7401
7445
  record.authRequiredSignaled = false;
7402
7446
  if (event.type === "message_update" && event.message && (event.message?.role === "assistant")) {
7447
+ void reportEphemeralMilestone("firstTokenAt");
7403
7448
  transcripts.persistIntermediateFromEvent(record, event, false);
7404
7449
  }
7405
7450
  if (event.type === "message_end" && event.message && (event.message?.role === "assistant")) {
@@ -7540,7 +7585,7 @@ function attachSessionListeners(record) {
7540
7585
  body: `${sessionNotifyLabel(record)} failed its last turn — tap to see what went wrong.`,
7541
7586
  });
7542
7587
  }
7543
- else if (!record.isWorking && !record.remoteActive && (record.backgroundTaskCount ?? 0) === 0) {
7588
+ else if (!record.isWorking && !record.remoteActive && clients.size === 0 && (relay?.clientCount ?? 0) === 0 && (record.backgroundTaskCount ?? 0) === 0) {
7544
7589
  void sendNotificationHint({
7545
7590
  kind: "session_done",
7546
7591
  sessionId: record.id,
@@ -7943,7 +7988,7 @@ async function createSession(workspace = defaultWorkspace, sessionFile, opts = {
7943
7988
  // made governance unable to tell that a defaulted session had full access and
7944
7989
  // also let a later node-default change alter its tier on resume.
7945
7990
  const policyWorkspace = requestedSessionFile ? (restoredWorktree?.path ?? storedMeta?.workspace ?? workspace) : workspace;
7946
- const sessionSafety = projectSafety(policyWorkspace, sandboxTier(opts.sandbox ?? storedMeta?.sandbox), opts.approvalMode ?? approvalMode);
7991
+ const sessionSafety = projectSafety(policyWorkspace, sandboxTier(opts.sandbox ?? storedMeta?.sandbox), opts.approvalMode ?? approvalModeFrom(storedMeta?.approvalMode) ?? approvalMode);
7947
7992
  const sessionSandbox = sessionSafety.sandbox;
7948
7993
  const rt = await ensureRuntimeAvailable(opts.runtimeId ?? storedMeta?.runtimeId, sessionSandbox);
7949
7994
  const allowedAgents = loadProjectPolicy(policyWorkspace)?.routing?.allowedAgents;
@@ -8510,7 +8555,7 @@ const runTerms = createRunTerminals({
8510
8555
  loadRunLog: (termId) => runLogs.load(termId),
8511
8556
  listAllSessions,
8512
8557
  listProvidersUnified,
8513
- pushModelAuthToControlPlane: () => pushModelAuthToControlPlane(),
8558
+ pushModelAuthToControlPlane: () => pushModelAuthToControlPlane(false, true),
8514
8559
  listPiSessions: async () => {
8515
8560
  // `bivy run pi` is an agent-owned native TUI and writes to Pi's own store;
8516
8561
  // governed Pi chats write to Bivy's isolated store. Search both so a native
@@ -0,0 +1,21 @@
1
+ import { buildForkHistory, normalizeMessages } from "./transcript-normal.js";
2
+ /** An EventLog is a display mirror, not an agent's native conversation store.
3
+ * Reconstruct through the existing runtime-neutral history-import capability.
4
+ * Never call a missing native file a successful resume or silently start empty.
5
+ * This is portable replay, not byte-identical native runtime restoration. */
6
+ export async function rebuildSnapshotRuntime(info, runtime, messages, workspace) {
7
+ if (runtime.id !== info.runtimeId)
8
+ throw new Error("Snapshot runtime identity mismatch");
9
+ if (!runtime.capabilities.forkHistoryImport || !runtime.importHistoryForFork) {
10
+ throw new Error("This runtime cannot replay snapshot history; the stored transcript is retained");
11
+ }
12
+ const history = buildForkHistory(normalizeMessages(messages, {
13
+ sourceRuntimeId: info.runtimeId, title: info.name, createdAt: new Date().toISOString(),
14
+ }));
15
+ if (!history.length)
16
+ throw new Error("Snapshot contains no replayable conversation");
17
+ const imported = await runtime.importHistoryForFork(history, { workspace, cwd: workspace, model: info.model });
18
+ if (!imported?.sessionFile?.trim())
19
+ throw new Error("Snapshot history import returned no resume reference");
20
+ return imported.sessionFile;
21
+ }
@@ -34,7 +34,7 @@ export async function buildSessionSnapshot(sessionId, roomKey, deps) {
34
34
  // has no cursor, so buildReplFrame emits a zero-record frame rather than null).
35
35
  if (!frame || (frame.records.length === 0 && !frame.checkpointCommit))
36
36
  return null;
37
- return seal(roomKey, JSON.stringify(frame));
37
+ return seal(roomKey, JSON.stringify({ ...frame, sessionInfo: deps.sessionInfo?.(sessionId) }));
38
38
  }
39
39
  /**
40
40
  * Decrypt and apply a sealed snapshot onto a fresh machine: rewrite the session's
@@ -45,6 +45,8 @@ export async function buildSessionSnapshot(sessionId, roomKey, deps) {
45
45
  */
46
46
  export async function applySessionSnapshot(sealed, roomKey, deps) {
47
47
  const frame = JSON.parse(open(roomKey, sealed));
48
+ if (deps.expectedSessionId && frame.sessionId !== deps.expectedSessionId)
49
+ throw new Error("Snapshot session identity mismatch");
48
50
  const applier = new StandbyApplier(deps);
49
51
  const ack = await applier.receive(frame);
50
52
  // We always ship a FULL frame + full bundle, so a fresh applier applies it
@@ -54,6 +56,7 @@ export async function applySessionSnapshot(sealed, roomKey, deps) {
54
56
  throw new Error(`snapshot apply failed: ${ack.status}`);
55
57
  }
56
58
  return {
59
+ sessionInfo: frame.sessionInfo,
57
60
  runtimeSessionRef: frame.runtimeSessionRef,
58
61
  recordCount: frame.records.length,
59
62
  checkpointCommit: frame.checkpointCommit,
@@ -0,0 +1,6 @@
1
+ export function clearTurnActivity(record) {
2
+ record.isWorking = false;
3
+ record.remoteActive = false;
4
+ record.lastActivity = undefined;
5
+ record.workingStartedAt = undefined;
6
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bivy/bivy",
3
- "version": "0.16.24",
3
+ "version": "0.16.25-staging.2",
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.",
@@ -56,6 +56,9 @@
56
56
  "@earendil-works/pi-coding-agent": "0.85.1"
57
57
  },
58
58
  "overrides": {
59
+ "fast-uri": "3.1.6",
60
+ "hono": "4.13.5",
61
+ "qs": "6.16.0",
59
62
  "@hono/node-server": "2.0.12",
60
63
  "@modelcontextprotocol/sdk": "1.30.0",
61
64
  "@earendil-works/pi-coding-agent": {
@@ -63,7 +66,6 @@
63
66
  "undici": "8.10.0"
64
67
  },
65
68
  "brace-expansion": "5.0.9",
66
- "fast-uri": "4.1.4",
67
69
  "nanoid": "3.3.18",
68
70
  "undici": "8.10.0"
69
71
  },