@bivy/bivy 0.6.0 → 0.7.0-staging.100

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.
@@ -9,12 +9,13 @@
9
9
  // session-scoped (workspace-local files preferred) so a failure or a concurrent
10
10
  // session can't corrupt config: we snapshot the exact bytes and restore them.
11
11
  //
12
- // Only JSON configs are handled (Claude, Gemini, OpenCode, generic .mcp.json).
13
- // TOML/YAML-config agents are skipped — they still run and are governed by the
14
- // FS + network channels. Unit-tested in test/harness-mcp-inject.test.ts.
12
+ // JSON configs (Claude, Gemini, generic .mcp.json) use the universal
13
+ // `mcpServers` shape; OpenCode's project `opencode.json` uses its own `mcp`
14
+ // shape (see routeOpenCodeThroughProxy). TOML/YAML (Codex, Goose) go through
15
+ // the format-specific writers. Unit-tested in test/harness-mcp-inject.test.ts.
15
16
  import fs from "node:fs";
16
17
  import path from "node:path";
17
- import { agentMcpConfigTargets, bivyToolsServerSpec, routeThroughProxy, withBivyToolsServer, } from "./mcp-config.js";
18
+ import { agentMcpConfigTargets, bivyToolsServerSpec, isOpenCodeConfigFile, routeOpenCodeThroughProxy, routeThroughProxy, toOpenCodeLocalServer, withBivyToolsServer, withOpenCodeBivyToolsServer, } from "./mcp-config.js";
18
19
  import { injectTomlMcp, injectYamlMcp, insertTomlServer } from "./mcp-config-formats.js";
19
20
  /** The proxy launcher Bivy injects — `bivy mcp-proxy …`. */
20
21
  export function bivyProxyLauncher(bivyCommand = "bivy") {
@@ -23,7 +24,9 @@ export function bivyProxyLauncher(bivyCommand = "bivy") {
23
24
  /**
24
25
  * Inject the proxy into a single JSON config file. Returns a restore thunk
25
26
  * (a no-op if the file was absent, unreadable, non-JSON, or had no stdio
26
- * servers to route). Never throws.
27
+ * servers to route). OpenCode project configs (`opencode.json`) use the
28
+ * OpenCode `mcp` shape; everything else uses the universal `mcpServers` shape.
29
+ * Never throws.
27
30
  */
28
31
  export function injectJsonMcpConfig(filePath, launcher) {
29
32
  let original;
@@ -40,7 +43,9 @@ export function injectJsonMcpConfig(filePath, launcher) {
40
43
  catch {
41
44
  return { injected: false, restore: () => { } };
42
45
  }
43
- const result = routeThroughProxy(parsed, launcher);
46
+ const result = isOpenCodeConfigFile(filePath)
47
+ ? routeOpenCodeThroughProxy(parsed, launcher)
48
+ : routeThroughProxy(parsed, launcher);
44
49
  if (result.rewritten.length === 0)
45
50
  return { injected: false, restore: () => { } };
46
51
  // Preserve the file's indentation feel by re-serializing with 2 spaces; the
@@ -116,7 +121,9 @@ export function injectMcpConfigFile(filePath, launcher) {
116
121
  * servers a file already has), this CREATES the config when absent so an agent
117
122
  * that ships no MCP config still gets the tool. Handles the most-specific JSON
118
123
  * config (session-local for claude/gemini/opencode/generic) and Codex's TOML
119
- * (`~/.codex/config.toml` — Codex has no project-local option). restore() deletes
124
+ * (`~/.codex/config.toml` — Codex has no project-local option). OpenCode gets
125
+ * its native `{ mcp: { bivy: { type: "local", command: [...] } } }` shape — the
126
+ * universal `mcpServers` key is rejected by OpenCode's schema. restore() deletes
120
127
  * a file it created and rewrites the exact original bytes of one it modified.
121
128
  * Idempotent (a `bivy` server already present is a no-op, so concurrent sessions
122
129
  * sharing a global config don't double up). Best-effort; never throws. Goose YAML
@@ -131,6 +138,7 @@ export function injectBivyToolsForSession(agentId, ctx, bivyCommand = "bivy") {
131
138
  return { injected: [], restore: () => { } };
132
139
  const spec = bivyToolsServerSpec({ sessionId: ctx.sessionId, endpoint: ctx.endpoint, bivyCommand });
133
140
  const ext = path.extname(target).toLowerCase();
141
+ const openCode = agentId === "opencode" || isOpenCodeConfigFile(target);
134
142
  const existed = fs.existsSync(target);
135
143
  let original;
136
144
  if (existed) {
@@ -142,7 +150,22 @@ export function injectBivyToolsForSession(agentId, ctx, bivyCommand = "bivy") {
142
150
  }
143
151
  }
144
152
  let nextContent;
145
- if (ext === ".json") {
153
+ if (ext === ".json" && openCode) {
154
+ let parsed = {};
155
+ if (original !== undefined) {
156
+ try {
157
+ parsed = JSON.parse(original);
158
+ }
159
+ catch {
160
+ return { injected: [], restore: () => { } };
161
+ }
162
+ }
163
+ const { config, added } = withOpenCodeBivyToolsServer(parsed, toOpenCodeLocalServer(spec));
164
+ if (!added)
165
+ return { injected: [], restore: () => { } };
166
+ nextContent = `${JSON.stringify(config, null, 2)}\n`;
167
+ }
168
+ else if (ext === ".json") {
146
169
  let parsed = {};
147
170
  if (original !== undefined) {
148
171
  try {
@@ -18,6 +18,34 @@
18
18
  // networking, unit-tested in test/harness-net-proxy.test.ts.
19
19
  import http from "node:http";
20
20
  import net from "node:net";
21
+ /** Allow every destination (the proxy's default — pure observe-and-log). */
22
+ export const allowAllDecider = () => ({ allow: true });
23
+ /**
24
+ * Deny every destination. Used for a per-session egress proxy that enforces the
25
+ * `read-only` sandbox tier's "no network" contract for agents whose own sandbox
26
+ * doesn't (see egress.ts). Node-local traffic never reaches here — the proxy env's
27
+ * NO_PROXY exempts localhost — so the agent can still reach the daemon's own MCP/API.
28
+ */
29
+ export function denyAllDecider(reason = "read-only sandbox: outbound network is disabled") {
30
+ return () => ({ allow: false, reason });
31
+ }
32
+ /**
33
+ * Allow only hosts in `hosts` (exact, or a subdomain of a listed apex — "api.x.com"
34
+ * matches an entry "x.com"), denying everything else. The building block for a
35
+ * per-workflow egress allowlist that never touches the node-global decider. Host
36
+ * matching is case-insensitive; an empty list denies all.
37
+ */
38
+ export function allowlistDecider(hosts, reason = "not on this session's egress allowlist") {
39
+ const allow = new Set(hosts.map((h) => h.trim().toLowerCase()).filter(Boolean));
40
+ return (host) => {
41
+ const h = host.trim().toLowerCase();
42
+ for (const entry of allow) {
43
+ if (h === entry || h.endsWith(`.${entry}`))
44
+ return { allow: true };
45
+ }
46
+ return { allow: false, reason };
47
+ };
48
+ }
21
49
  /** Split "host:port" (CONNECT target) into parts, defaulting the port. */
22
50
  export function parseHostPort(authority, defaultPort) {
23
51
  // IPv6 literal like [::1]:443
package/dist/metadata.js CHANGED
@@ -169,6 +169,24 @@ export class MetadataStore {
169
169
  this.data.sessions[id] = { ...prev, resumePending: pending, updatedAt: nowIso() };
170
170
  this.save();
171
171
  }
172
+ /** Set/clear the durable auto-resume time (rate/usage-limit recovery). Pass
173
+ * null to clear. No-op when the row is missing or already in the requested
174
+ * state, so it never churns the file on the hot turn path. */
175
+ setResumeAt(id, resumeAt) {
176
+ const prev = this.data.sessions[id];
177
+ if (!prev)
178
+ return;
179
+ const next = resumeAt ?? undefined;
180
+ if ((prev.resumeAt ?? undefined) === next)
181
+ return;
182
+ this.data.sessions[id] = { ...prev, resumeAt: next, updatedAt: nowIso() };
183
+ this.save();
184
+ }
185
+ /** Sessions with a durable auto-resume time set — the resume sweep re-arms
186
+ * these after a restart. */
187
+ sessionsWithResumeAt() {
188
+ return Object.values(this.data.sessions).filter((s) => typeof s.resumeAt === "string" && s.resumeAt);
189
+ }
172
190
  /** Look up a session's durable metadata by id, or by session-file path. */
173
191
  getSession(idOrPath) {
174
192
  if (!idOrPath)
@@ -50,6 +50,48 @@ export function parseResetsAt(raw) {
50
50
  const iso = /(20\d\d-\d\d-\d\dT[\d:.]+(?:Z|[+-]\d\d:?\d\d))/.exec(raw);
51
51
  return iso?.[1];
52
52
  }
53
+ /**
54
+ * Parse a bare wall-clock reset time — the shape Claude's subscription limits
55
+ * surface, e.g. `resets 12am (UTC)`, `resets at 3pm UTC`, `resets 09:00 UTC` —
56
+ * into the ISO timestamp of its NEXT occurrence (interpreted as UTC, which is
57
+ * what these messages state). Returns undefined when there's no clear clock
58
+ * time, so a relative phrase ("resets in 2 hours", handled by
59
+ * parseRetryAfterMs) or an unrelated number never masquerades as a reset.
60
+ *
61
+ * NB: a bare time-of-day can't say WHICH day, so for a multi-day window (a
62
+ * "weekly limit") this resolves to the nearest matching midnight, which may be
63
+ * earlier than the real reset. Prefer a structured resetsAtHint when available.
64
+ */
65
+ export function parseResetClock(raw, nowMs) {
66
+ const m = /reset[a-z]*\s+(?:at\s+)?(\d{1,2})(?::(\d{2}))?\s*(am|pm)?/i.exec(raw);
67
+ if (!m)
68
+ return undefined;
69
+ const meridiem = m[3]?.toLowerCase();
70
+ // Require a real clock signal — a meridiem, an explicit minutes field, or a
71
+ // trailing UTC/GMT marker — so a bare "resets 5 nodes" can't parse as 05:00.
72
+ const tzFollows = /\b(?:utc|gmt)\b/i.test(raw.slice(m.index));
73
+ if (!meridiem && m[2] === undefined && !tzFollows)
74
+ return undefined;
75
+ let hour = Number(m[1]);
76
+ const minute = m[2] ? Number(m[2]) : 0;
77
+ if (hour > 23 || minute > 59)
78
+ return undefined;
79
+ if (meridiem === "am") {
80
+ if (hour === 12)
81
+ hour = 0;
82
+ }
83
+ else if (meridiem === "pm") {
84
+ if (hour !== 12)
85
+ hour += 12;
86
+ }
87
+ if (hour > 23)
88
+ return undefined;
89
+ const now = new Date(nowMs);
90
+ let target = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate(), hour, minute, 0, 0);
91
+ if (target <= nowMs)
92
+ target += 86_400_000; // already past today → next day's occurrence
93
+ return new Date(target).toISOString();
94
+ }
53
95
  // Ordered classifiers: the FIRST match wins, so more-specific/actionable
54
96
  // conditions are tested before broader ones (auth 401 before generic HTTP
55
97
  // noise; explicit billing/quota before a bare rate-limit; context-window before
@@ -58,7 +100,12 @@ const CLASSIFIERS = [
58
100
  { condition: "auth_failed", test: (r) => isAnthropicAuthError(r) },
59
101
  {
60
102
  condition: "credits_exhausted",
61
- test: (r) => /\b402\b|payment required|insufficient\s+(?:credit|quota|balance|funds)|credit balance (?:is )?too low|quota (?:exceeded|exhausted)|billing|(?:usage|session) limit (?:reached|hit)|(?:you(?:'ve| have)\s+)?hit your limit|out of credits|plan (?:limit|allowance)/i.test(r),
103
+ // Includes subscription usage caps — a Claude "5-hour" or "weekly" window
104
+ // hit reads "you've hit your weekly limit · resets 12am (UTC)". The window
105
+ // qualifier (weekly/daily/5-hour/7-day) is optional and may sit between
106
+ // "your" and "limit", so it must not break the match (it did before — the
107
+ // word "weekly" left these limits classified "unknown" and never resumed).
108
+ test: (r) => /\b402\b|payment required|insufficient\s+(?:credit|quota|balance|funds)|credit balance (?:is )?too low|quota (?:exceeded|exhausted)|billing|(?:usage|session|weekly|daily|monthly|5[\s-]?hour|7[\s-]?day)[\s-]?limit(?:\s+(?:reached|hit))?|(?:you(?:'ve| have)\s+)?hit your (?:(?:weekly|daily|monthly|session|usage|5[\s-]?hour|7[\s-]?day)\s+)?limit|out of credits|plan (?:limit|allowance)/i.test(r),
62
109
  },
63
110
  {
64
111
  condition: "rate_limited",
@@ -86,7 +133,7 @@ const CLASSIFIERS = [
86
133
  * recovery metadata. Unmatched failures are `"unknown"` — deliberately left for
87
134
  * a human rather than blindly retried.
88
135
  */
89
- export function classifyFailure(error) {
136
+ export function classifyFailure(error, opts = {}) {
90
137
  const raw = rawText(error).slice(0, 2000);
91
138
  const condition = CLASSIFIERS.find((c) => c.test(raw))?.condition ?? "unknown";
92
139
  const out = { condition, raw };
@@ -95,7 +142,11 @@ export function classifyFailure(error) {
95
142
  const retryAfterMs = parseRetryAfterMs(raw);
96
143
  if (retryAfterMs !== undefined)
97
144
  out.retryAfterMs = retryAfterMs;
98
- const resetsAt = parseResetsAt(raw);
145
+ // Reset time, most-authoritative first: a structured hint the caller
146
+ // supplied (the provider's own usage snapshot), then an ISO stamp in the
147
+ // text, then a bare wall-clock ("resets 12am (UTC)") resolved to its next
148
+ // occurrence.
149
+ const resetsAt = opts.resetsAtHint ?? parseResetsAt(raw) ?? parseResetClock(raw, opts.now ?? Date.now());
99
150
  if (resetsAt !== undefined)
100
151
  out.resetsAt = resetsAt;
101
152
  }
@@ -34,7 +34,7 @@ export function createRunPolicy(deps = {}) {
34
34
  const now = deps.now ?? Date.now;
35
35
  return {
36
36
  decide(ctx) {
37
- const classified = classifyFailure(ctx.error);
37
+ const classified = classifyFailure(ctx.error, { now: now(), resetsAtHint: ctx.resetsAtHint });
38
38
  const { condition } = classified;
39
39
  const rule = findRule(ruleset, condition, context);
40
40
  if (!rule)
@@ -73,6 +73,7 @@ export function createRunPolicy(deps = {}) {
73
73
  delayMs,
74
74
  condition,
75
75
  summary: `${condition}: transient — retrying (attempt ${nextAttempt}/${rule.maxAttempts})${timing}.`,
76
+ ...(resetDelayMs !== undefined && classified.resetsAt ? { resetsAt: classified.resetsAt } : {}),
76
77
  };
77
78
  }
78
79
  // action === "reroute": walk the chain from the current cursor, skipping
@@ -21,6 +21,17 @@
21
21
  // whether to suppress the turn's error toast before kicking off the async swap +
22
22
  // retry (`applyReroute`). Reroute happens only at the turn boundary, so there is
23
23
  // no partial-work hazard.
24
+ //
25
+ // It also plans the OTHER in-place recovery a live session can do: waiting out a
26
+ // provider usage/rate limit and re-sending the same prompt when the window
27
+ // resets (`planResume`). Unlike a reroute (which the controller applies itself),
28
+ // a resume can be hours away and must survive a daemon restart, so scheduling +
29
+ // persistence live in the caller (src/server.ts) — the controller only decides
30
+ // whether a resume is warranted and by when.
31
+ /** Below this, a "retry" is ordinary backoff (seconds) — not worth deferring an
32
+ * interactive turn for; let it surface. A real usage/rate window reset is
33
+ * minutes-to-days out and always clears this bar. */
34
+ const MIN_RESUME_DELAY_MS = 60_000;
24
35
  const defaultSleep = (ms) => new Promise((r) => setTimeout(r, ms));
25
36
  export class SessionRerouteController {
26
37
  deps;
@@ -50,6 +61,47 @@ export class SessionRerouteController {
50
61
  attempt: this.attempt,
51
62
  rerouteCount: this.rerouteCount,
52
63
  });
64
+ if (decision.action !== "reroute")
65
+ return null;
66
+ return this.rerouteFrom(decision, currentModel);
67
+ }
68
+ /**
69
+ * Decide whether this turn error should be recovered by WAITING for a provider
70
+ * usage/rate limit to reset and re-sending the same prompt. Returns a plan the
71
+ * caller should persist + schedule, or null (surface the error as usual).
72
+ *
73
+ * `resetsAtHint` is the authoritative reset time when the caller has one (the
74
+ * provider's structured usage snapshot) — essential for a multi-day "weekly"
75
+ * window, whose error text only states a time-of-day. `now` is injectable for
76
+ * deterministic tests. Pure w.r.t. the controller's counters.
77
+ */
78
+ planResume(rawError, currentModel, opts = {}) {
79
+ if (this.applying)
80
+ return null;
81
+ const now = opts.now ?? Date.now();
82
+ const decision = this.deps.policy.decide({
83
+ routing: { model: currentModel },
84
+ error: rawError,
85
+ attempt: this.attempt,
86
+ rerouteCount: this.rerouteCount,
87
+ resetsAtHint: opts.resetsAtHint,
88
+ });
89
+ if (decision.action !== "retry")
90
+ return null;
91
+ // Only defer for a concrete recovery window — a provider reset, or a delay
92
+ // long enough that it's clearly a limit rather than routine backoff.
93
+ if (decision.resetsAt === undefined && decision.delayMs < MIN_RESUME_DELAY_MS)
94
+ return null;
95
+ const resumeAt = decision.resetsAt ?? new Date(now + decision.delayMs).toISOString();
96
+ return { condition: decision.condition, summary: decision.summary, delayMs: Math.max(0, decision.delayMs), resumeAt };
97
+ }
98
+ /** Advance the attempt budget once the caller has committed to a resume, so a
99
+ * limit that re-fires after the reset counts toward `maxAttempts` and can
100
+ * eventually exhaust (→ park) instead of looping forever. */
101
+ noteResumeApplied() {
102
+ this.attempt += 1;
103
+ }
104
+ rerouteFrom(decision, currentModel) {
53
105
  if (decision.action !== "reroute")
54
106
  return null;
55
107
  const model = decision.routing.model;
@@ -164,6 +164,25 @@ export async function resolveBranchBaseRef(repoDir, branch) {
164
164
  throw new Error(`Branch "${branch}" was not found on the remote.`);
165
165
  }
166
166
  }
167
+ /**
168
+ * Base ref for ADOPTING a source branch onto a fresh clone on another node (a
169
+ * cross-node fork). Prefers the pushed `origin/<branch>` so the source's
170
+ * committed work travels; falls back to the repo's default branch when the
171
+ * source branch was never pushed (best-effort — any uncommitted work still
172
+ * arrives via the fork's dirty patch). Fetches first so `origin/<branch>` is
173
+ * current. Contrast with `resolveBranchBaseRef`, which is user-facing and throws
174
+ * on a missing branch; a fork must degrade rather than fail.
175
+ */
176
+ export async function resolveAdoptBaseRef(repoDir, branch) {
177
+ await fetchOrigin(repoDir);
178
+ try {
179
+ await exec("git", ["-C", repoDir, "rev-parse", "--verify", "--quiet", `origin/${branch}`], { cwd: repoDir });
180
+ return `origin/${branch}`;
181
+ }
182
+ catch {
183
+ return resolveDefaultBaseRef(repoDir);
184
+ }
185
+ }
167
186
  /**
168
187
  * Whether an existing Bivy-owned checkout at `dest` can be reused as-is, i.e. it
169
188
  * has a `.git` entry AND `git rev-parse` accepts it as a real repository. A
@@ -71,6 +71,47 @@ export function anthropicCredentialPreflight(env, deps = {}) {
71
71
  export function isAnthropicAuthError(raw) {
72
72
  return isModelAuthError(raw);
73
73
  }
74
+ /**
75
+ * Safely validate that an Anthropic API key actually grants access, rather than
76
+ * trusting mere presence (B1). Uses `GET /v1/models` — an authenticated, read-only,
77
+ * zero-token endpoint — so it never spends inference budget or mutates anything.
78
+ *
79
+ * Only API keys (`sk-…`) are probed: OAuth subscription tokens and the `claude`
80
+ * CLI's on-disk/Keychain login have no comparably safe check, so for those we
81
+ * return `{ probed: false, ok: true }` and let presence stand. Any non-auth
82
+ * failure (network down, 5xx, timeout) is also `probed: false` — we only report
83
+ * `ok: false` when the provider affirmatively rejects the credential (401/403).
84
+ */
85
+ export async function probeAnthropicAccess(apiKey, deps = {}) {
86
+ const key = apiKey?.trim();
87
+ // Subscription/OAuth tokens are not API keys; there is no safe read probe.
88
+ if (!key || !key.startsWith("sk-"))
89
+ return { probed: false, ok: true, reason: "no API key to probe" };
90
+ const doFetch = deps.fetch ?? fetch;
91
+ const base = (deps.baseUrl ?? "https://api.anthropic.com").replace(/\/+$/, "");
92
+ const controller = new AbortController();
93
+ const timer = setTimeout(() => controller.abort(), deps.timeoutMs ?? 4000);
94
+ try {
95
+ const res = await doFetch(`${base}/v1/models?limit=1`, {
96
+ method: "GET",
97
+ headers: { "x-api-key": key, "anthropic-version": "2023-06-01" },
98
+ signal: controller.signal,
99
+ });
100
+ if (res.ok)
101
+ return { probed: true, ok: true, status: res.status };
102
+ if (res.status === 401 || res.status === 403) {
103
+ return { probed: true, ok: false, status: res.status, reason: `Anthropic rejected the credential (${res.status})` };
104
+ }
105
+ // 429/5xx/etc. — the key may be fine; don't falsely fail readiness.
106
+ return { probed: false, ok: true, status: res.status, reason: `inconclusive (${res.status})` };
107
+ }
108
+ catch (error) {
109
+ return { probed: false, ok: true, reason: error instanceof Error ? error.message : "probe failed" };
110
+ }
111
+ finally {
112
+ clearTimeout(timer);
113
+ }
114
+ }
74
115
  /**
75
116
  * Phrase an SDK error for the user: an auth failure gets the sign-in guidance
76
117
  * appended; anything else is returned unchanged.
@@ -168,7 +168,16 @@ export function writeCodexRollout(history, cwd) {
168
168
  const stamp = iso.replace(/[:.]/g, "-").replace(/Z$/, "");
169
169
  const file = path.join(dir, `rollout-${stamp}-${id}.jsonl`);
170
170
  const records = [
171
- { type: "session_meta", timestamp: iso, payload: { id, timestamp: iso, cwd, cli_version: "bivy-fork" } },
171
+ // Codex's SessionMeta parser requires `originator`. Without it the first
172
+ // record is discarded as malformed; `thread/resume` then reaches the first
173
+ // response_item and fails with "does not start with session metadata".
174
+ // Keep both ids: current Codex accepts legacy `id`-only records, but writing
175
+ // the canonical `session_id` makes the synthetic rollout valid directly.
176
+ {
177
+ type: "session_meta",
178
+ timestamp: iso,
179
+ payload: { session_id: id, id, timestamp: iso, cwd, originator: "bivy", cli_version: "bivy-fork" },
180
+ },
172
181
  ...history.map((message) => ({
173
182
  type: "response_item",
174
183
  timestamp: iso,
@@ -34,6 +34,37 @@ function isStoredCredential(value) {
34
34
  function providerId(id) {
35
35
  return String(id ?? "").trim().toLowerCase();
36
36
  }
37
+ /**
38
+ * Should an `incoming` credential replace the `local` one during a non-destructive
39
+ * `importAll` merge? Pure and exported so the convergence rule is unit-testable
40
+ * without a vault. Rules:
41
+ * - No local entry → take the incoming one.
42
+ * - Only OAuth-vs-OAuth needs freshness arbitration (an api-key set/replace, or a
43
+ * type switch, keeps the existing "incoming wins on a real content change").
44
+ * - A snapshot that omits the refresh token must never clobber a usable one —
45
+ * rotated refresh tokens are single-use, so an incoming with a blank refresh is
46
+ * strictly worse than a local one that still has it.
47
+ * - Prefer the token minted LATER by `refreshedAt` (monotonic mint order) when
48
+ * both carry it; otherwise fall back to the access-token `expires`. In both
49
+ * cases a tie KEEPS the local credential (strictly-greater wins), so an equal
50
+ * stamp can't needlessly churn/rotate the vault, and clock skew can't let an
51
+ * equal-`expires` stale token win.
52
+ */
53
+ export function preferIncomingCredential(local, incoming) {
54
+ if (!local)
55
+ return true;
56
+ if (local.type !== "oauth" || incoming.type !== "oauth")
57
+ return true;
58
+ const localRefresh = String(local.refresh ?? "").trim();
59
+ const incomingRefresh = String(incoming.refresh ?? "").trim();
60
+ if (!incomingRefresh && localRefresh)
61
+ return false;
62
+ const lt = Number(local.refreshedAt);
63
+ const it = Number(incoming.refreshedAt);
64
+ if (Number.isFinite(lt) && Number.isFinite(it))
65
+ return it > lt;
66
+ return (Number(incoming.expires) || 0) > (Number(local.expires) || 0);
67
+ }
37
68
  /**
38
69
  * Encrypted, cross-process-locked credential vault backed by `<vaultDir>/auth.enc`.
39
70
  *
@@ -202,12 +233,10 @@ export class BivyCredentialStore {
202
233
  if (!id || !isStoredCredential(incoming))
203
234
  continue;
204
235
  const local = vault[id];
205
- if (incoming.type === "oauth" && local?.type === "oauth") {
206
- const localExpires = Number(local.expires) || 0;
207
- const incomingExpires = Number(incoming.expires) || 0;
208
- if (localExpires > incomingExpires)
209
- continue;
210
- }
236
+ // Freshest-wins, rotation-safe (see preferIncomingCredential): a lagging
237
+ // or refresh-less snapshot must not overwrite a fresher local login.
238
+ if (!preferIncomingCredential(local, incoming))
239
+ continue;
211
240
  if (!(id in vault))
212
241
  imported += 1;
213
242
  // Only mark dirty on a real content change, so a snapshot that merely