@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.
- package/README.md +15 -6
- package/bin/acp-shim.mjs +128 -14
- package/bin/agent-manifest.json +39 -0
- package/bin/bivy.mjs +99 -13
- package/bin/patch-pi-dependencies.mjs +22 -16
- package/dist/automation-checks.js +68 -0
- package/dist/bivy-login.js +13 -0
- package/dist/control-plane-tasks.js +39 -8
- package/dist/diagnostics.js +75 -0
- package/dist/github-tasks.js +27 -7
- package/dist/guard.js +51 -8
- package/dist/harness/egress.js +64 -1
- package/dist/harness/mcp-config.js +89 -6
- package/dist/harness/mcp-inject.js +31 -8
- package/dist/harness/net-proxy.js +28 -0
- package/dist/metadata.js +18 -0
- package/dist/policy/conditions.js +54 -3
- package/dist/policy/run-policy.js +2 -1
- package/dist/policy/session-reroute.js +52 -0
- package/dist/repo-workspace.js +19 -0
- package/dist/runtime/anthropic-preflight.js +41 -0
- package/dist/runtime/codex-sessions.js +10 -1
- package/dist/runtime/credential-store.js +35 -6
- package/dist/runtime/index.js +177 -20
- package/dist/runtime/oauth/model-oauth.js +5 -4
- package/dist/runtime/process.js +33 -9
- package/dist/runtime/protocol.js +64 -1
- package/dist/runtime/slash-commands.js +246 -0
- package/dist/server.js +789 -108
- package/dist/session/attachment-store.js +99 -11
- package/dist/session/event-log.js +75 -11
- package/dist/session/fork-dirty.js +41 -3
- package/dist/session/revert-file.js +44 -0
- package/dist/session/turn-watchdog.js +19 -0
- package/package.json +6 -3
|
@@ -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
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
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).
|
|
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 =
|
|
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).
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
package/dist/repo-workspace.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|