@bivy/bivy 0.18.5-staging.1 → 0.18.5-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.
@@ -0,0 +1,44 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ import { createAppJwt } from "./github-app-auth.js";
4
+ /** App credentials cannot call /user/repos. Enumerate each installation instead. */
5
+ export async function listGithubAppRepos(apps, fetchImpl = fetch) {
6
+ const repos = new Map();
7
+ async function pages(path, token, field) {
8
+ const rows = [];
9
+ for (let page = 1;; page++) {
10
+ const res = await fetchImpl(`https://api.github.com${path}?per_page=100&page=${page}`, {
11
+ headers: { authorization: `Bearer ${token}`, accept: "application/vnd.github+json", "user-agent": "bivy" },
12
+ });
13
+ if (!res.ok)
14
+ throw new Error(`GitHub responded ${res.status} while listing repositories`);
15
+ const body = await res.json();
16
+ const batch = field ? body[field] : body;
17
+ if (!Array.isArray(batch))
18
+ throw new Error("GitHub returned an invalid repository listing");
19
+ rows.push(...batch);
20
+ if (batch.length < 100)
21
+ return rows;
22
+ }
23
+ }
24
+ for (const app of apps) {
25
+ const jwt = createAppJwt(app.cfg.appId, app.cfg.privateKeyPem, Math.floor(Date.now() / 1000));
26
+ for (const installation of await pages("/app/installations", jwt)) {
27
+ if (installation.suspended_at)
28
+ continue;
29
+ const token = await app.cache.get(String(installation.id));
30
+ for (const repo of await pages("/installation/repositories", token, "repositories")) {
31
+ if (typeof repo.full_name !== "string" || !repo.full_name)
32
+ continue;
33
+ repos.set(repo.full_name.toLowerCase(), {
34
+ slug: repo.full_name,
35
+ description: typeof repo.description === "string" ? repo.description : "",
36
+ private: Boolean(repo.private),
37
+ pushedAt: typeof repo.pushed_at === "string" ? repo.pushed_at : undefined,
38
+ defaultBranch: typeof repo.default_branch === "string" ? repo.default_branch : undefined,
39
+ });
40
+ }
41
+ }
42
+ }
43
+ return [...repos.values()].sort((a, b) => (b.pushedAt ?? "").localeCompare(a.pushedAt ?? "") || a.slug.localeCompare(b.slug));
44
+ }
@@ -0,0 +1,22 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ /** Validate keys against the vault before caching them or sharing them with peers.
4
+ * A stale local key must not hide a fresh peer wrap; an unusable wrap must never
5
+ * be persisted as though key recovery succeeded. No credential data is changed.
6
+ */
7
+ export function recoverModelAuthKey(options) {
8
+ if (options.localKey) {
9
+ try {
10
+ return { key: options.localKey, value: options.decrypt(options.localKey) };
11
+ }
12
+ catch { /* Try the peer's replacement before requesting another wrap. */ }
13
+ }
14
+ if (options.unwrap) {
15
+ try {
16
+ const key = options.unwrap();
17
+ return { key, value: options.decrypt(key) };
18
+ }
19
+ catch { /* The caller must reject this exact wrap and request a new one. */ }
20
+ }
21
+ return undefined;
22
+ }
package/dist/server.js CHANGED
@@ -142,6 +142,7 @@ import { computeSessionContract } from "./session/session-contract.js";
142
142
  import { SecretVault, resolveSecret } from "./secrets.js";
143
143
  import { deviceFlowClientId, requestDeviceCode, pollAccessTokenOnce, REPO_CONNECT_SCOPE } from "./github-device-auth.js";
144
144
  import { InstallationTokenCache, createAppJwt, resolveInstallationId } from "./github-app-auth.js";
145
+ import { listGithubAppRepos } from "./github-app-repos.js";
145
146
  import { loadGitHubAppConfigs, orderAppsForOwner, listGitHubApps, removeGitHubApp, upsertGitHubApp, privateKeyIdFor, } from "./github-apps.js";
146
147
  import { buildAppManifest, convertManifest, renderManifestForm } from "./github-app-manifest.js";
147
148
  import { encryptGithubAppEnvelope, decryptGithubAppEnvelope, readLocalGithubAppVaultKey, writeLocalGithubAppVaultKey, forgetLocalGithubAppVaultKey, mintLocalGithubAppVaultKey, } from "./github-app-vault.js";
@@ -149,6 +150,7 @@ import { redactSecrets } from "./redact.js";
149
150
  import { getSttConfig, isSttProvider, removeSttKey, setSttKey, setSttProvider, transcribeAudio, MAX_AUDIO_BYTES, } from "./stt.js";
150
151
  import { synthesizeOpenAiSpeech } from "./tts.js";
151
152
  import { seal, open } from "./e2e.js";
153
+ import { recoverModelAuthKey } from "./model-auth-key-recovery.js";
152
154
  import { RunDelegationService, parseDelegationSource } from "./run-tools.js";
153
155
  import { ControlPlaneTaskPoller, resolveControlPlaneTaskConfig, } from "./control-plane-tasks.js";
154
156
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -3365,20 +3367,31 @@ async function syncModelAuthFromControlPlane() {
3365
3367
  }
3366
3368
  const targetVault = hostedCustody ? hostedData.hostedVault : data.vault;
3367
3369
  let vaultKeyB64 = hostedCustody ? readHostedModelAuthVaultKey() : readLocalModelAuthVaultKey();
3368
- if (!hostedCustody && !vaultKeyB64 && data.wrappedKey?.wrappedKey) {
3369
- vaultKeyB64 = pairingStore.unwrapFromNodePublicKey(data.wrappedKey.wrappedByPublicKey, data.wrappedKey.wrappedKey);
3370
- writeLocalModelAuthVaultKey(vaultKeyB64);
3371
- }
3372
3370
  // Hosted runners may decrypt ONLY the separately encrypted snapshot of
3373
3371
  // records that the user explicitly granted to unattended execution.
3374
3372
  if (hostedCustody && !vaultKeyB64 && hostedData.hostedKey && hostedData.hostedVault && Buffer.from(hostedData.hostedKey, "base64").length === 32) {
3375
3373
  vaultKeyB64 = hostedData.hostedKey;
3376
3374
  writeHostedModelAuthVaultKey(vaultKeyB64);
3377
3375
  }
3378
- if (targetVault?.ciphertext && vaultKeyB64) {
3376
+ if (targetVault?.ciphertext && (vaultKeyB64 || (!hostedCustody && data.wrappedKey))) {
3379
3377
  let decrypted;
3380
3378
  try {
3381
- decrypted = decryptModelAuthEnvelope(targetVault.ciphertext, vaultKeyB64);
3379
+ if (hostedCustody) {
3380
+ decrypted = decryptModelAuthEnvelope(targetVault.ciphertext, vaultKeyB64);
3381
+ }
3382
+ else {
3383
+ const wrapped = data.wrappedKey;
3384
+ const recovered = recoverModelAuthKey({
3385
+ localKey: vaultKeyB64,
3386
+ unwrap: wrapped ? () => pairingStore.unwrapFromNodePublicKey(wrapped.wrappedByPublicKey, wrapped.wrappedKey) : undefined,
3387
+ decrypt: (key) => decryptModelAuthEnvelope(targetVault.ciphertext, key),
3388
+ });
3389
+ if (!recovered)
3390
+ throw new Error("No available key can decrypt the account vault");
3391
+ vaultKeyB64 = recovered.key;
3392
+ decrypted = recovered.value;
3393
+ writeLocalModelAuthVaultKey(vaultKeyB64);
3394
+ }
3382
3395
  }
3383
3396
  catch (error) {
3384
3397
  // Most commonly this node cached the previous generation while another
@@ -3394,7 +3407,10 @@ async function syncModelAuthFromControlPlane() {
3394
3407
  else {
3395
3408
  forgetLocalModelAuthVaultKey();
3396
3409
  lastPushedModelAuthCiphertext = "";
3397
- await modelAuthFetch("/node/model-auth-key/request", { method: "POST", body: JSON.stringify({ publicKey: pairingStore.nodePublicKeyB64() }) });
3410
+ await modelAuthFetch("/node/model-auth-key/request", {
3411
+ method: "POST",
3412
+ body: JSON.stringify({ publicKey: pairingStore.nodePublicKeyB64(), rejectedWrappedKey: data.wrappedKey?.wrappedKey }),
3413
+ });
3398
3414
  ensureModelAuthColdStart();
3399
3415
  }
3400
3416
  console.warn("[auth-sync] cached vault key is stale; requested the rotated key:", error.message);
@@ -4828,6 +4844,7 @@ async function ensureGitHubApps() {
4828
4844
  function invalidateGitHubApps() {
4829
4845
  githubApps = undefined;
4830
4846
  installationIdByRepo.clear();
4847
+ invalidateGithubListingCaches();
4831
4848
  }
4832
4849
  /**
4833
4850
  * The node-side token seam. A GitHub App work item (carries an installationId,
@@ -10467,13 +10484,19 @@ async function createControlPlaneHook(kind = "github") {
10467
10484
  return data;
10468
10485
  }
10469
10486
  // List the GitHub repos the node can reach with its own token (for picking a
10470
- // git workspace). Privacy-preserving: uses only the token stored on this
10471
- // machine (env or `gh`), never the control plane. Returns `authed: false` with
10487
+ // git workspace). Uses local App installations when available, otherwise the
10488
+ // token stored on this machine (env or `gh`). Returns `authed: false` with
10472
10489
  // an empty list when there's no token so the UI falls back to manual owner/repo.
10473
10490
  async function listAccessibleRepos() {
10474
10491
  if (reposCache && Date.now() - reposCache.at < REPO_LIST_TTL_MS)
10475
10492
  return reposCache.val;
10476
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
+ }
10477
10500
  const token = await resolveGitHubToken();
10478
10501
  if (!token)
10479
10502
  return { authed: false, repos: [], reason: (await ghCliInstalled()) ? "gh-unauthed" : "no-token" };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bivy/bivy",
3
- "version": "0.18.5-staging.1",
3
+ "version": "0.18.5-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.",