@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.
- package/dist/agents/claude-code/integration.js +1 -0
- package/dist/agents/codex/integration.js +1 -0
- package/dist/agents/pi/integration.js +1 -0
- package/dist/controllers/credential-commands.js +8 -1
- package/dist/credentials/document.js +37 -18
- package/dist/credentials/store.js +5 -18
- package/dist/remote/relay-client.js +16 -0
- package/dist/repo-workspace.js +5 -1
- package/dist/runtime/canonical-session.js +31 -0
- package/dist/runtime/codex-auth.js +15 -10
- package/dist/runtime/credential-ingest.js +12 -1
- package/dist/runtime/credential-provisioning.js +5 -1
- package/dist/runtime/host.js +3 -1
- package/dist/runtime/index.js +12 -1
- package/dist/server.js +80 -35
- package/dist/session/snapshot-runtime.js +21 -0
- package/dist/session/snapshot.js +4 -1
- package/dist/session/turn-activity.js +6 -0
- package/package.json +4 -2
|
@@ -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,
|
|
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
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
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
|
-
|
|
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
|
-
*
|
|
66
|
-
*
|
|
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
|
-
|
|
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();
|
package/dist/repo-workspace.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
// (
|
|
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"
|
|
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:
|
|
158
|
-
refreshedAt
|
|
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: {
|
|
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;
|
package/dist/runtime/host.js
CHANGED
|
@@ -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
|
-
|
|
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();
|
package/dist/runtime/index.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
2883
|
-
|
|
2884
|
-
|
|
2885
|
-
|
|
2886
|
-
|
|
2887
|
-
|
|
2888
|
-
|
|
2889
|
-
|
|
2890
|
-
|
|
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
|
-
//
|
|
3338
|
-
//
|
|
3339
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
3406
|
-
//
|
|
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
|
-
|
|
6850
|
-
|
|
6851
|
-
|
|
6852
|
-
|
|
6853
|
-
|
|
6854
|
-
|
|
6855
|
-
|
|
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 =
|
|
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
|
|
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
|
|
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
|
+
}
|
package/dist/session/snapshot.js
CHANGED
|
@@ -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,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bivy/bivy",
|
|
3
|
-
"version": "0.16.
|
|
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
|
},
|