@bivy/bivy 0.16.25-staging.2 → 0.16.25-staging.20
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/bin/agent-manifest.json +3 -0
- package/dist/agents/pi/runtime.js +27 -3
- package/dist/agents/profiles.js +32 -15
- package/dist/controllers/credential-commands.js +8 -3
- package/dist/credentials/document.js +35 -10
- package/dist/credentials/store.js +17 -0
- package/dist/hosted-custody.js +30 -0
- package/dist/runtime/process.js +10 -2
- package/dist/server.js +54 -17
- package/dist/session/transcript-persistence.js +9 -1
- package/package.json +1 -1
package/bin/agent-manifest.json
CHANGED
|
@@ -268,8 +268,29 @@ class PiSession {
|
|
|
268
268
|
* reload. Async because that resolution reads the store.
|
|
269
269
|
*/
|
|
270
270
|
async getModels() {
|
|
271
|
-
const
|
|
272
|
-
|
|
271
|
+
const runtime = this.session.modelRuntime;
|
|
272
|
+
try {
|
|
273
|
+
const models = await runtime.getAvailable();
|
|
274
|
+
return models.map((model) => toModelInfo(model, true));
|
|
275
|
+
}
|
|
276
|
+
catch (error) {
|
|
277
|
+
// The runtime's availability refresh is a Promise.all across every
|
|
278
|
+
// provider's auth check, so one broken provider (an unreachable custom
|
|
279
|
+
// endpoint, a single failing credential read) rejects the whole catalog
|
|
280
|
+
// and the picker shows nothing. Degrade to per-provider listing and
|
|
281
|
+
// skip only the broken ones; rethrow only when truly nothing survives.
|
|
282
|
+
const models = [];
|
|
283
|
+
for (const provider of runtime.getProviders()) {
|
|
284
|
+
try {
|
|
285
|
+
for (const model of await runtime.getAvailable(provider.id))
|
|
286
|
+
models.push(toModelInfo(model, true));
|
|
287
|
+
}
|
|
288
|
+
catch { /* this provider is broken — the rest still list */ }
|
|
289
|
+
}
|
|
290
|
+
if (models.length)
|
|
291
|
+
return models;
|
|
292
|
+
throw error instanceof Error ? error : new Error(String(error));
|
|
293
|
+
}
|
|
273
294
|
}
|
|
274
295
|
/**
|
|
275
296
|
* Every model Pi knows about — built-in and custom, regardless of whether
|
|
@@ -281,7 +302,10 @@ class PiSession {
|
|
|
281
302
|
async getAllModels() {
|
|
282
303
|
const runtime = this.session.modelRuntime;
|
|
283
304
|
// Refresh availability first so `hasConfiguredAuth` reflects current creds.
|
|
284
|
-
|
|
305
|
+
// Best-effort: a failed refresh (one broken provider rejects the shared
|
|
306
|
+
// Promise.all) must not blank the full catalog — the flags then reflect
|
|
307
|
+
// the last successful refresh, which the picker can still act on.
|
|
308
|
+
await runtime.getAvailable().catch(() => { });
|
|
285
309
|
return runtime.getModels().map((model) => toModelInfo(model, runtime.hasConfiguredAuth(model.provider)));
|
|
286
310
|
}
|
|
287
311
|
/** Reload the projected models.json in-place so a live session immediately
|
package/dist/agents/profiles.js
CHANGED
|
@@ -370,32 +370,49 @@ export const AGENT_PROFILES = {
|
|
|
370
370
|
// UUID (sessions live under ~/.grok/sessions/<cwd>/<id>/). The interactive
|
|
371
371
|
// TUI uses the same store via `grok --resume <id>`. Model ids match
|
|
372
372
|
// `grok models` for the official CLI (override with BIVY_GROK_MODELS).
|
|
373
|
-
|
|
374
|
-
//
|
|
375
|
-
//
|
|
376
|
-
//
|
|
377
|
-
//
|
|
378
|
-
//
|
|
379
|
-
//
|
|
380
|
-
|
|
381
|
-
//
|
|
382
|
-
|
|
373
|
+
// `--always-approve` auto-approves every tool execution. Without it, Grok's
|
|
374
|
+
// headless `-p` turn emits a tool_call and then waits for the client to
|
|
375
|
+
// approve it over the streaming-json (ACP) channel; Bivy governs effects at
|
|
376
|
+
// the sandbox tier and closes stdin, so the pending call races to a "User
|
|
377
|
+
// cancelled the execution" — i.e. Grok could never actually run a tool under
|
|
378
|
+
// Bivy. The flag is the Grok analogue of Cursor's `--force`, Copilot's
|
|
379
|
+
// `--allow-all-tools`, Droid's `--auto`, etc.
|
|
380
|
+
args: ["--always-approve", "-p"],
|
|
381
|
+
// The current CLI's `--output-format streaming-json` emits NDJSON of the
|
|
382
|
+
// agent-native ACP session updates: assistant text, a {type:"thought"}
|
|
383
|
+
// reasoning sidecar, and tool_call/tool_call_update frames. The shared
|
|
384
|
+
// tolerant generic-stream-json parser understands this shape (and the ACP
|
|
385
|
+
// session/update envelope other CLIs use), so Grok gets faithful transcripts
|
|
386
|
+
// — answer prose, a thinking sidecar, and tool cards — without a Grok-specific
|
|
387
|
+
// adapter. Keep the plain args as the explicit BIVY_AGENT_STRUCTURED=0
|
|
388
|
+
// fallback.
|
|
389
|
+
jsonArgs: ["--output-format", "streaming-json", "--always-approve", "-p"],
|
|
383
390
|
parserId: "generic-stream-json",
|
|
391
|
+
// Resume mirrors the fresh-launch recipe (structured output + auto-approve)
|
|
392
|
+
// so a continued turn streams and runs tools exactly like the first one.
|
|
384
393
|
resume: {
|
|
385
|
-
template: ["--resume", "{id}", "-p"],
|
|
394
|
+
template: ["--output-format", "streaming-json", "--resume", "{id}", "--always-approve", "-p"],
|
|
386
395
|
historyLoader: "grok",
|
|
387
396
|
},
|
|
388
397
|
model: {
|
|
389
398
|
flag: "-m",
|
|
390
399
|
models: [
|
|
391
400
|
// Official Grok CLI (1.x) advertises grok-4.6 as the default subscription
|
|
392
|
-
// model (verified against
|
|
393
|
-
//
|
|
394
|
-
//
|
|
395
|
-
//
|
|
401
|
+
// model (verified against the authenticated `~/.grok/models_cache.json`,
|
|
402
|
+
// origin cli-chat-proxy.grok.com/v1/models); the older grok-4.5 /
|
|
403
|
+
// grok-4-latest / grok-code-fast-1 ids now return "unknown model id".
|
|
404
|
+
// Keep the list honest; operators can override with BIVY_GROK_MODELS if
|
|
405
|
+
// their install exposes more.
|
|
396
406
|
{ id: "grok-4.6", name: "Grok 4.6", provider: "xai" },
|
|
397
407
|
],
|
|
398
408
|
},
|
|
409
|
+
// Grok's reasoning models expose a `--reasoning-effort <EFFORT>` flag
|
|
410
|
+
// (verified against `grok --help`, 1.0.0); the authenticated model catalog
|
|
411
|
+
// lists low/medium/high/xhigh with `high` as the default. `{level}` fills
|
|
412
|
+
// the value; insertAt: 0 keeps the trailing `-p` prompt flag last so the
|
|
413
|
+
// prompt still lands as its value. Threads through the resume path too, so a
|
|
414
|
+
// continued turn keeps the chosen effort. Override with BIVY_GROK_THINKING.
|
|
415
|
+
thinking: { levels: ["low", "medium", "high", "xhigh"], default: "high", template: ["--reasoning-effort", "{level}"], insertAt: 0 },
|
|
399
416
|
promptMode: "argv",
|
|
400
417
|
install: {
|
|
401
418
|
kind: "curl",
|
|
@@ -95,7 +95,8 @@ export function createCredentialCommands(deps) {
|
|
|
95
95
|
async "credential.remove"(msg, ctx) {
|
|
96
96
|
try {
|
|
97
97
|
await removeProviderCredential(credsDir, String(msg.provider ?? ""), String(msg.label ?? ""));
|
|
98
|
-
|
|
98
|
+
// Deleting a granted credential must also remove its escrowed cloud copy.
|
|
99
|
+
await deps.pushModelAuthToControlPlane({ allowEmptyHostedEscrow: true });
|
|
99
100
|
await deps.refreshSessionAfterAuth();
|
|
100
101
|
deps.sendEvent({ type: "credentials.records", records: await listCredentialRecords(credsDir) });
|
|
101
102
|
deps.broadcast({ type: "providers.list", providers: await deps.listProvidersUnified() });
|
|
@@ -111,7 +112,8 @@ export function createCredentialCommands(deps) {
|
|
|
111
112
|
try {
|
|
112
113
|
const sync = msg.sync === "node" ? "node" : "account";
|
|
113
114
|
await setCredentialSync(credsDir, String(msg.provider ?? ""), String(msg.label ?? ""), sync);
|
|
114
|
-
|
|
115
|
+
// Demoting to machine-only revokes any custody grant; let it clear the escrow.
|
|
116
|
+
await deps.pushModelAuthToControlPlane({ allowEmptyHostedEscrow: sync === "node" });
|
|
115
117
|
deps.sendEvent({ type: "credentials.records", records: await listCredentialRecords(credsDir) });
|
|
116
118
|
ctx.reply({ type: "credential.sync.set.ok", requestId: msg.requestId });
|
|
117
119
|
}
|
|
@@ -127,7 +129,10 @@ export function createCredentialCommands(deps) {
|
|
|
127
129
|
const previous = (await listCredentialRecords(credsDir)).find((record) => record.provider === provider.trim().toLowerCase() && record.label === label)?.unattended === true;
|
|
128
130
|
try {
|
|
129
131
|
await setCredentialUnattended(credsDir, provider, label, msg.unattended === true);
|
|
130
|
-
|
|
132
|
+
// Fail loud: the grant reply must reflect whether the encrypted cloud
|
|
133
|
+
// copy actually reached the control plane. An explicit disable is the
|
|
134
|
+
// one caller allowed to publish an empty escrow snapshot.
|
|
135
|
+
await deps.pushModelAuthToControlPlane({ throwOnFailure: true, allowEmptyHostedEscrow: msg.unattended !== true });
|
|
131
136
|
deps.sendEvent({ type: "credentials.records", records: await listCredentialRecords(credsDir) });
|
|
132
137
|
ctx.reply({ type: "credential.unattended.set.ok", requestId: msg.requestId });
|
|
133
138
|
}
|
|
@@ -87,17 +87,22 @@ 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
|
+
const stable = (value) => {
|
|
91
|
+
if (Array.isArray(value))
|
|
92
|
+
return value.map(stable);
|
|
93
|
+
if (!value || typeof value !== "object")
|
|
94
|
+
return value;
|
|
95
|
+
return Object.fromEntries(Object.entries(value)
|
|
96
|
+
.sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)
|
|
97
|
+
.map(([key, child]) => [key, stable(child)]));
|
|
98
|
+
};
|
|
90
99
|
function canonicalCredential(credential) {
|
|
91
100
|
const { updatedAt: _drop, ...content } = credential;
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
+
}
|
|
103
|
+
/** Stable record content sans the store-owned stamp — the final merge tie-break. */
|
|
104
|
+
function canonicalRecord(record) {
|
|
105
|
+
const { updatedAt: _drop, ...content } = record;
|
|
101
106
|
return JSON.stringify(stable(content));
|
|
102
107
|
}
|
|
103
108
|
/**
|
|
@@ -132,6 +137,19 @@ export function preferIncomingRecord(local, incoming) {
|
|
|
132
137
|
const incomingOauth = oauthOf(incoming);
|
|
133
138
|
if (!localOauth || !incomingOauth)
|
|
134
139
|
return true;
|
|
140
|
+
// Identical token content means this is a metadata-only change (an
|
|
141
|
+
// unattended-runs grant/revoke, a sync-tier move). Token freshness cannot
|
|
142
|
+
// order those, so the newer record write wins — without this, a grant made
|
|
143
|
+
// on one machine never reaches its peers, and a peer's next hosted-escrow
|
|
144
|
+
// push would read as "nothing granted". Equal stamps fall back to canonical
|
|
145
|
+
// record content so both merge directions still converge.
|
|
146
|
+
if (canonicalCredential(localOauth) === canonicalCredential(incomingOauth)) {
|
|
147
|
+
const localAt = Number(local.updatedAt) || 0;
|
|
148
|
+
const incomingAt = Number(incoming.updatedAt) || 0;
|
|
149
|
+
if (incomingAt !== localAt)
|
|
150
|
+
return incomingAt > localAt;
|
|
151
|
+
return canonicalRecord(incoming) > canonicalRecord(local);
|
|
152
|
+
}
|
|
135
153
|
return preferIncomingOAuthCredential(localOauth, incomingOauth);
|
|
136
154
|
}
|
|
137
155
|
/** A tombstone wins only when it is newer than the record it would remove. */
|
|
@@ -251,7 +269,14 @@ export function mergeDocuments(local, incomingCredentials, incomingDeletedAt = {
|
|
|
251
269
|
continue;
|
|
252
270
|
if (!(key in credentials))
|
|
253
271
|
imported += 1;
|
|
254
|
-
|
|
272
|
+
// An incoming record with no opinion on unattended custody (undefined — the
|
|
273
|
+
// v2 wire and plaintext ingest never carry the flag) must not strip an
|
|
274
|
+
// explicit local grant/revoke: agent-side token refreshes flow through this
|
|
275
|
+
// merge, and dropping the flag would make the node's next escrow push
|
|
276
|
+
// silently revoke the encrypted cloud copy the user still relies on.
|
|
277
|
+
credentials[key] = localRecord && incoming.unattended === undefined && localRecord.unattended !== undefined
|
|
278
|
+
? { ...incoming, unattended: localRecord.unattended }
|
|
279
|
+
: incoming;
|
|
255
280
|
const incomingUpdatedAt = Number(incoming.updatedAt);
|
|
256
281
|
if (!Number.isFinite(deletedAt[key]) || incomingUpdatedAt > deletedAt[key])
|
|
257
282
|
delete deletedAt[key];
|
|
@@ -554,6 +554,23 @@ export class BivyCredentialStore {
|
|
|
554
554
|
// engine (merge-never-destroy, freshest-OAuth-wins, rotation-safe,
|
|
555
555
|
// tombstone-newer-wins — see document.ts).
|
|
556
556
|
const incoming = migrateToV3({ v: 2, providers: snapshot, deletedAt });
|
|
557
|
+
// The v2 wire carries no record metadata, so migrateToV3 synthesizes
|
|
558
|
+
// defaults (sync: "account", origin: "bivy", no unattended flag). Those
|
|
559
|
+
// defaults must not clobber local intent — a machine-only sync tier or an
|
|
560
|
+
// unattended-runs custody grant — when only the token content is newer
|
|
561
|
+
// (e.g. an agent's own TUI refreshed the OAuth token set). Inherit the
|
|
562
|
+
// existing record's metadata; the merge engine still decides freshness.
|
|
563
|
+
for (const [key, record] of Object.entries(incoming.credentials)) {
|
|
564
|
+
const existing = document.credentials[key];
|
|
565
|
+
if (!existing)
|
|
566
|
+
continue;
|
|
567
|
+
incoming.credentials[key] = {
|
|
568
|
+
...record,
|
|
569
|
+
sync: existing.sync,
|
|
570
|
+
origin: existing.origin,
|
|
571
|
+
...(existing.unattended !== undefined ? { unattended: existing.unattended } : {}),
|
|
572
|
+
};
|
|
573
|
+
}
|
|
557
574
|
const result = mergeDocuments(document, incoming.credentials, incoming.deletedAt);
|
|
558
575
|
if (result.changed)
|
|
559
576
|
this.writeDocument(result.document);
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
// Copyright (c) 2026 Petter André Sjulstad
|
|
3
|
+
// One pure policy decision: does THIS daemon hold only control-plane-custodied
|
|
4
|
+
// credentials (the separately escrowed unattended snapshot), or is it a normal
|
|
5
|
+
// personal node that owns its vault?
|
|
6
|
+
//
|
|
7
|
+
// The distinction matters everywhere model-auth sync branches: a custody node
|
|
8
|
+
// never pushes/pulls the account vault, never publishes the unattended escrow,
|
|
9
|
+
// and treats the control plane's filtered snapshot as an authoritative set
|
|
10
|
+
// (reconcile deletes what a newer snapshot omits). Getting this wrong on a
|
|
11
|
+
// personal node silently breaks credential sync AND makes the unattended-runs
|
|
12
|
+
// toggle a no-op — the "Allowed — encrypted cloud copy enabled" UI then lies,
|
|
13
|
+
// and every Bivy Cloud session fails with "credential didn't reach Bivy Cloud".
|
|
14
|
+
/**
|
|
15
|
+
* True when this daemon must run with hosted-custody semantics:
|
|
16
|
+
* - `BIVY_HOSTED_CREDENTIAL_CUSTODY` — the dedicated flag the provisioner sets
|
|
17
|
+
* on managed (Bivy Cloud) machines; always authoritative.
|
|
18
|
+
* - `BIVY_GITHUB_HOSTED_TASKS` + `BIVY_EPHEMERAL` — back-compat for
|
|
19
|
+
* provisioned user-owned queue runners that predate the dedicated flag.
|
|
20
|
+
*
|
|
21
|
+
* `BIVY_GITHUB_HOSTED_TASKS` ALONE is not custody: it is persisted onto any
|
|
22
|
+
* long-lived personal node when a GitHub App is connected (it only means "poll
|
|
23
|
+
* the hosted task queue"). Such a node owns its own logins and must keep full
|
|
24
|
+
* personal-vault semantics — account sync, escrow publishing, loud toggles.
|
|
25
|
+
*/
|
|
26
|
+
export function hostedCustodyNode(env = process.env) {
|
|
27
|
+
if (env.BIVY_HOSTED_CREDENTIAL_CUSTODY)
|
|
28
|
+
return true;
|
|
29
|
+
return Boolean(env.BIVY_GITHUB_HOSTED_TASKS && env.BIVY_EPHEMERAL);
|
|
30
|
+
}
|
package/dist/runtime/process.js
CHANGED
|
@@ -369,8 +369,14 @@ class ProcessSession {
|
|
|
369
369
|
this.nativeSessionRef = learnedRef;
|
|
370
370
|
this.upstreamSessionStarted = true;
|
|
371
371
|
}
|
|
372
|
-
|
|
373
|
-
|
|
372
|
+
// Sync the parser's final transcript into this.messages BEFORE emitting the
|
|
373
|
+
// terminal events (message_end/turn_end/agent_end). The daemon persists a
|
|
374
|
+
// base-transcript snapshot synchronously on message_end/turn_end by reading
|
|
375
|
+
// getMessages() — so if the push happened after the emit loop (as it once
|
|
376
|
+
// did), that snapshot saw an empty turn and the agent's answer + paired
|
|
377
|
+
// tool blocks were never persisted, vanishing on reload for every
|
|
378
|
+
// structured-pipe CLI (Grok, Goose, Gemini, …). Populating first makes
|
|
379
|
+
// getMessages() authoritative the instant message_end fires.
|
|
374
380
|
if (!messagesPushed && events.some((e) => e.type === "agent_end")) {
|
|
375
381
|
messagesPushed = true;
|
|
376
382
|
for (const message of activeParser.messages())
|
|
@@ -381,6 +387,8 @@ class ProcessSession {
|
|
|
381
387
|
if (usage)
|
|
382
388
|
this.lastUsage = usage;
|
|
383
389
|
}
|
|
390
|
+
for (const event of events)
|
|
391
|
+
this.emit(event);
|
|
384
392
|
};
|
|
385
393
|
const feedParser = (activeParser, text) => {
|
|
386
394
|
lineBuf += text;
|
package/dist/server.js
CHANGED
|
@@ -80,6 +80,7 @@ import { forceAbortTurn } from "./session/abort-recovery.js";
|
|
|
80
80
|
import { runRequiredAutomationChecks } from "./automation-checks.js";
|
|
81
81
|
import { configToLegacySettings, mergeLegacyIntoNodeConfig, readNodeConfig, writeNodeConfig } from "./node-config.js";
|
|
82
82
|
import { loadProjectPolicy, resolveProjectSafety } from "./project-policy.js";
|
|
83
|
+
import { hostedCustodyNode } from "./hosted-custody.js";
|
|
83
84
|
import { PolicyEngine } from "./policy/policy-engine.js";
|
|
84
85
|
import { SessionAllowRules } from "./policy/session-allow.js";
|
|
85
86
|
import { TerminalManager } from "./terminal.js";
|
|
@@ -1966,7 +1967,7 @@ const RELAY_COMMANDS = {
|
|
|
1966
1967
|
credsDir,
|
|
1967
1968
|
sendEvent: (event) => relay?.sendEvent(event),
|
|
1968
1969
|
broadcast,
|
|
1969
|
-
pushModelAuthToControlPlane,
|
|
1970
|
+
pushModelAuthToControlPlane: (opts) => pushModelAuthToControlPlane(false, opts?.throwOnFailure === true, opts?.allowEmptyHostedEscrow === true),
|
|
1970
1971
|
refreshSessionAfterAuth,
|
|
1971
1972
|
listProvidersUnified,
|
|
1972
1973
|
}),
|
|
@@ -2407,7 +2408,19 @@ const RELAY_COMMANDS = {
|
|
|
2407
2408
|
// this a models.list answered for one agent (e.g. Codex's models) could
|
|
2408
2409
|
// linger on the composer/picker after the user switched to another agent
|
|
2409
2410
|
// (e.g. Claude) — the "Claude shows Codex models" bug.
|
|
2410
|
-
|
|
2411
|
+
let event;
|
|
2412
|
+
try {
|
|
2413
|
+
event = await modelsListEventFor(record);
|
|
2414
|
+
}
|
|
2415
|
+
catch (error) {
|
|
2416
|
+
// A catalog read failure must never answer with silence: the generic
|
|
2417
|
+
// relay catch would swallow the rejection and the client (notably a
|
|
2418
|
+
// Cloud launch waiting to validate its saved model) could only guess
|
|
2419
|
+
// via timeout. Name the real reason on the session instead.
|
|
2420
|
+
relay?.sendEvent({ type: "session.error", sessionId: requestedSessionId ?? record.id, error: `Couldn't read this machine's model catalog: ${error instanceof Error ? error.message : String(error)}` });
|
|
2421
|
+
return;
|
|
2422
|
+
}
|
|
2423
|
+
relay?.sendEvent(event);
|
|
2411
2424
|
},
|
|
2412
2425
|
"models.prefetch"(msg) {
|
|
2413
2426
|
// The composer's agent picker opened: warm the scratch session for each
|
|
@@ -2569,7 +2582,8 @@ const RELAY_COMMANDS = {
|
|
|
2569
2582
|
}
|
|
2570
2583
|
}
|
|
2571
2584
|
await removeProvider(credsDir, id);
|
|
2572
|
-
|
|
2585
|
+
// Deleting a granted credential must also remove its escrowed cloud copy.
|
|
2586
|
+
await pushModelAuthToControlPlane(false, false, true);
|
|
2573
2587
|
await refreshSessionAfterAuth();
|
|
2574
2588
|
broadcast({ type: "provider.oauth.reset", provider: id, ok: true, providers: await listProvidersUnified() });
|
|
2575
2589
|
broadcast({ type: "providers.list", providers: await listProvidersUnified() });
|
|
@@ -2581,7 +2595,8 @@ const RELAY_COMMANDS = {
|
|
|
2581
2595
|
async "provider.remove"(msg) {
|
|
2582
2596
|
try {
|
|
2583
2597
|
await removeProvider(credsDir, String(msg.provider ?? msg.id ?? ""));
|
|
2584
|
-
|
|
2598
|
+
// Deleting a granted credential must also remove its escrowed cloud copy.
|
|
2599
|
+
await pushModelAuthToControlPlane(false, false, true);
|
|
2585
2600
|
await refreshSessionAfterAuth();
|
|
2586
2601
|
broadcast({ type: "providers.list", providers: await listProvidersUnified() });
|
|
2587
2602
|
}
|
|
@@ -3065,7 +3080,11 @@ const hostedImportedRecordsPath = path.join(appDir, "model-auth-hosted-records.j
|
|
|
3065
3080
|
let lastPushedModelAuthCiphertext = "";
|
|
3066
3081
|
let lastPushedHostedModelAuthCiphertext = "";
|
|
3067
3082
|
let lastPushedHostedModelAuthRevision = -1;
|
|
3068
|
-
|
|
3083
|
+
// Custody semantics are keyed on the dedicated flag (plus an ephemeral
|
|
3084
|
+
// back-compat case) — see src/hosted-custody.ts. A persistent personal node
|
|
3085
|
+
// that polls the hosted GitHub queue is NOT a custody guest: it owns its
|
|
3086
|
+
// logins and must keep account sync and unattended-escrow publishing.
|
|
3087
|
+
const isHostedCustodyNode = () => hostedCustodyNode();
|
|
3069
3088
|
function readLocalModelAuthVaultKey() {
|
|
3070
3089
|
try {
|
|
3071
3090
|
const parsed = JSON.parse(fs.readFileSync(modelAuthVaultKeyPath, "utf8"));
|
|
@@ -3376,12 +3395,19 @@ async function processModelAuthKeyRequests(requests) {
|
|
|
3376
3395
|
});
|
|
3377
3396
|
}
|
|
3378
3397
|
}
|
|
3379
|
-
async function pushHostedModelAuthToControlPlane() {
|
|
3398
|
+
async function pushHostedModelAuthToControlPlane(allowEmpty = false) {
|
|
3380
3399
|
const [records, revision] = await Promise.all([exportUnattendedRecords(credsDir), unattendedCredentialRevision(credsDir)]);
|
|
3381
3400
|
// A setup guest must not establish an empty snapshot when the credential is
|
|
3382
3401
|
// first saved (before the explicit grant command follows). Otherwise its
|
|
3383
3402
|
// one allowed initial write would be consumed by an unusable vault.
|
|
3384
|
-
|
|
3403
|
+
//
|
|
3404
|
+
// And an IMPLICIT empty push must never revoke the escrow: an empty snapshot
|
|
3405
|
+
// is how "disable unattended runs" removes the cloud copy, but every vault
|
|
3406
|
+
// change funnels through here — so a node whose copy simply lacks the grant
|
|
3407
|
+
// (not yet synced, or granted elsewhere) would otherwise wipe the encrypted
|
|
3408
|
+
// cloud copy the user's other machines just published. Only an explicit
|
|
3409
|
+
// local revoke/delete (allowEmpty) may publish an empty snapshot.
|
|
3410
|
+
if (Object.keys(records).length === 0 && (!allowEmpty || isHostedCustodyNode()))
|
|
3385
3411
|
return;
|
|
3386
3412
|
if (revision === lastPushedHostedModelAuthRevision)
|
|
3387
3413
|
return;
|
|
@@ -3413,7 +3439,7 @@ async function pushHostedModelAuthToControlPlane() {
|
|
|
3413
3439
|
throw new Error(`hosted model-auth push failed (${response?.status ?? "offline"})`);
|
|
3414
3440
|
}
|
|
3415
3441
|
}
|
|
3416
|
-
async function pushModelAuthToControlPlane(rotateKey = false, throwOnFailure = false) {
|
|
3442
|
+
async function pushModelAuthToControlPlane(rotateKey = false, throwOnFailure = false, allowEmptyHostedEscrow = false) {
|
|
3417
3443
|
if (!sessionAdvertiseTarget)
|
|
3418
3444
|
return;
|
|
3419
3445
|
// Piggyback the (plaintext, non-secret) provider status summary on every
|
|
@@ -3448,7 +3474,7 @@ async function pushModelAuthToControlPlane(rotateKey = false, throwOnFailure = f
|
|
|
3448
3474
|
writeLocalModelAuthVaultKey(vaultKeyB64);
|
|
3449
3475
|
const ciphertext = encryptModelAuthProviders(providers, deletedAt, localModels, vaultKeyB64, records, recordsDeletedAt);
|
|
3450
3476
|
if (!rotateKey && ciphertext === lastPushedModelAuthCiphertext) {
|
|
3451
|
-
await pushHostedModelAuthToControlPlane();
|
|
3477
|
+
await pushHostedModelAuthToControlPlane(allowEmptyHostedEscrow);
|
|
3452
3478
|
return;
|
|
3453
3479
|
}
|
|
3454
3480
|
const push = await modelAuthFetch("/node/model-auth-vault", { method: "PUT", body: JSON.stringify({ ciphertext, rotated: rotateKey }) });
|
|
@@ -3469,7 +3495,7 @@ async function pushModelAuthToControlPlane(rotateKey = false, throwOnFailure = f
|
|
|
3469
3495
|
// Publish a DIFFERENT ciphertext under a DIFFERENT key containing only
|
|
3470
3496
|
// records with `unattended:true`. Escrowing this key cannot decrypt the E2E
|
|
3471
3497
|
// account vault, which is the critical custody separation.
|
|
3472
|
-
await pushHostedModelAuthToControlPlane();
|
|
3498
|
+
await pushHostedModelAuthToControlPlane(allowEmptyHostedEscrow);
|
|
3473
3499
|
}
|
|
3474
3500
|
catch (error) {
|
|
3475
3501
|
console.warn("[auth-sync] could not push model auth:", error.message);
|
|
@@ -9295,7 +9321,7 @@ async function modelsListEventFor(record) {
|
|
|
9295
9321
|
const current = session.getCurrentModel();
|
|
9296
9322
|
const models = await publicModelsList(session, current);
|
|
9297
9323
|
const thinking = publicThinkingInfo(session);
|
|
9298
|
-
return { type: "models.list", sessionId: record.id, runtimeId: record.runtimeId, current: current ? publicModel(current, current) : null, models, thinking };
|
|
9324
|
+
return { type: "models.list", sessionId: record.id, runtimeId: record.runtimeId, modelSelection: getRuntime(record.runtimeId).capabilities.modelSelection !== false, current: current ? publicModel(current, current) : null, models, thinking };
|
|
9299
9325
|
}
|
|
9300
9326
|
function publicThinkingInfo(session) {
|
|
9301
9327
|
const supports = typeof session.supportsThinking === "function" ? session.supportsThinking() : false;
|
|
@@ -9322,7 +9348,7 @@ app.get("/api/models", async (req, res, next) => {
|
|
|
9322
9348
|
const current = session.getCurrentModel();
|
|
9323
9349
|
const models = await publicModelsList(session, current);
|
|
9324
9350
|
const thinking = publicThinkingInfo(session);
|
|
9325
|
-
res.json({ sessionId: record.id, current: current ? publicModel(current, current) : null, models, thinking });
|
|
9351
|
+
res.json({ sessionId: record.id, modelSelection: getRuntime(record.runtimeId).capabilities.modelSelection !== false, current: current ? publicModel(current, current) : null, models, thinking });
|
|
9326
9352
|
}
|
|
9327
9353
|
catch (error) {
|
|
9328
9354
|
next(error);
|
|
@@ -9678,7 +9704,8 @@ app.post("/api/auth/credentials", async (req, res, next) => {
|
|
|
9678
9704
|
app.delete("/api/auth/credentials/:provider/:label", async (req, res, next) => {
|
|
9679
9705
|
try {
|
|
9680
9706
|
await removeProviderCredential(credsDir, String(req.params.provider), String(req.params.label));
|
|
9681
|
-
|
|
9707
|
+
// Deleting a granted credential must also remove its escrowed cloud copy.
|
|
9708
|
+
await pushModelAuthToControlPlane(false, false, true);
|
|
9682
9709
|
await refreshSessionAfterAuth();
|
|
9683
9710
|
res.json({ ok: true, records: await listCredentialRecords(credsDir), providers: await listProvidersUnified() });
|
|
9684
9711
|
}
|
|
@@ -9690,7 +9717,8 @@ app.post("/api/auth/credentials/:provider/:label/availability", async (req, res,
|
|
|
9690
9717
|
try {
|
|
9691
9718
|
const sync = req.body?.sync === "node" ? "node" : "account";
|
|
9692
9719
|
await setCredentialSync(credsDir, String(req.params.provider), String(req.params.label), sync);
|
|
9693
|
-
|
|
9720
|
+
// Demoting to machine-only revokes any custody grant; let that clear the escrow.
|
|
9721
|
+
await pushModelAuthToControlPlane(false, false, sync === "node");
|
|
9694
9722
|
res.json({ ok: true, records: await listCredentialRecords(credsDir) });
|
|
9695
9723
|
}
|
|
9696
9724
|
catch (error) {
|
|
@@ -9698,12 +9726,21 @@ app.post("/api/auth/credentials/:provider/:label/availability", async (req, res,
|
|
|
9698
9726
|
}
|
|
9699
9727
|
});
|
|
9700
9728
|
app.post("/api/auth/credentials/:provider/:label/unattended", async (req, res, next) => {
|
|
9701
|
-
|
|
9702
|
-
|
|
9703
|
-
|
|
9729
|
+
const provider = String(req.params.provider);
|
|
9730
|
+
const label = String(req.params.label);
|
|
9731
|
+
const enable = req.body?.unattended === true;
|
|
9732
|
+
const previous = (await listCredentialRecords(credsDir).catch(() => []))
|
|
9733
|
+
.find((record) => record.provider === provider.trim().toLowerCase() && record.label === label)?.unattended === true;
|
|
9734
|
+
try {
|
|
9735
|
+
await setCredentialUnattended(credsDir, provider, label, enable);
|
|
9736
|
+
// Fail loud (mirrors the relay command): the UI must never claim an
|
|
9737
|
+
// encrypted cloud copy exists when custody publication failed. An explicit
|
|
9738
|
+
// disable is the one caller allowed to publish an empty escrow snapshot.
|
|
9739
|
+
await pushModelAuthToControlPlane(false, true, !enable);
|
|
9704
9740
|
res.json({ ok: true, records: await listCredentialRecords(credsDir) });
|
|
9705
9741
|
}
|
|
9706
9742
|
catch (error) {
|
|
9743
|
+
await setCredentialUnattended(credsDir, provider, label, previous).catch(() => { });
|
|
9707
9744
|
next(error);
|
|
9708
9745
|
}
|
|
9709
9746
|
});
|
|
@@ -31,7 +31,15 @@ function capThinkingForPersistence(text) {
|
|
|
31
31
|
function toolEventId(event) {
|
|
32
32
|
const toolCall = event.toolCall;
|
|
33
33
|
const input = (event.input || event.toolInput || event.args || toolCall?.input || {});
|
|
34
|
-
|
|
34
|
+
// Structured-pipe parsers (Grok/Goose/Gemini/… via the shared TurnAccumulator)
|
|
35
|
+
// emit a `tool_result` whose id lives under `result.toolCallId`, not at the top
|
|
36
|
+
// level. Without reading it here the result overlay was keyed `"<name>:"` and
|
|
37
|
+
// never paired with its `tool_call` overlay — orphaning the output in the
|
|
38
|
+
// transcript. Check the nested result id as a general shape, not a per-agent
|
|
39
|
+
// branch (protocol agents that already set a top-level id are unaffected).
|
|
40
|
+
const result = (event.result || event.toolResult);
|
|
41
|
+
const explicit = event.toolUseId || event.tool_use_id || event.toolCallId || event.callId || event.id || toolCall?.id ||
|
|
42
|
+
result?.toolCallId || result?.tool_use_id || result?.toolUseId || result?.id;
|
|
35
43
|
if (explicit)
|
|
36
44
|
return String(explicit);
|
|
37
45
|
return `${String(event.toolName || event.name || toolCall?.name || "tool")}:${String(input.path || input.file || input.filePath || input.command || input.cmd || input.query || "")}`;
|
package/package.json
CHANGED