@bivy/bivy 0.16.18-staging.8 → 0.16.18

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.
@@ -12,6 +12,7 @@
12
12
  // permission callback, which maps cleanly onto our generic `toolInterceptor`.
13
13
  // * The SDK is loaded with a dynamic import so it stays an *optional*
14
14
  // dependency: a Bivy install only needs it when this runtime is selected.
15
+ import { withSessionCredentials, credentialEnvFallback } from "../../credentials/session.js";
15
16
  import { createRequire } from "node:module";
16
17
  import { randomUUID } from "node:crypto";
17
18
  import { EventEmitter } from "node:events";
@@ -782,7 +783,7 @@ class ClaudeSession {
782
783
  async interactiveTuiCommand() {
783
784
  if (!claudeCliAvailable())
784
785
  return null;
785
- const env = await this.resolveCredentialEnv().catch(() => ({}));
786
+ const env = await this.resolveCredentialEnv().catch(credentialEnvFallback);
786
787
  return { command: "claude", args: ["--resume", this.sessionFile], env };
787
788
  }
788
789
  getMessages() {
@@ -956,7 +957,7 @@ class ClaudeSession {
956
957
  if (this.query)
957
958
  return this.refreshSupportedModels();
958
959
  try {
959
- const env = { ...process.env, ...depCacheEnv(this.cwd), ...this.runtimeOptions.env, ...(await this.resolveCredentialEnv().catch(() => ({}))) };
960
+ const env = { ...process.env, ...depCacheEnv(this.cwd), ...this.runtimeOptions.env, ...(await this.resolveCredentialEnv().catch(credentialEnvFallback)) };
960
961
  if (anthropicCredentialPreflight(env))
961
962
  return; // no credential — keep FALLBACK_MODELS
962
963
  await this.ensureStarted();
@@ -989,7 +990,7 @@ class ClaudeSession {
989
990
  // refresh it immediately even if its expiry claims it is still valid.
990
991
  // The resolver compares this under the vault lock, making concurrent
991
992
  // failures converge on one rotation.
992
- const credEnv = await this.resolveCredentialEnv(rejectedToken).catch(() => ({}));
993
+ const credEnv = await this.resolveCredentialEnv(rejectedToken).catch(credentialEnvFallback);
993
994
  const nextToken = authTokenFromEnv(credEnv);
994
995
  if (!nextToken || nextToken === this.spawnedToken)
995
996
  return false;
@@ -1039,8 +1040,8 @@ class ClaudeSession {
1039
1040
  try {
1040
1041
  cred = await store.getCredential(provider, { workspace: this.cwd, ...(rejectedToken ? { rejectedToken } : {}) });
1041
1042
  }
1042
- catch {
1043
- return {};
1043
+ catch (error) {
1044
+ return credentialEnvFallback(error);
1044
1045
  }
1045
1046
  if (!cred)
1046
1047
  return {};
@@ -1314,7 +1315,7 @@ class ClaudeSession {
1314
1315
  // reach the SDK, surface an actionable message instead of letting it spawn
1315
1316
  // and fail its first request with an opaque `401 Unauthorized`.
1316
1317
  if (!this.query) {
1317
- const env = { ...process.env, ...depCacheEnv(this.cwd), ...this.runtimeOptions.env, ...(await this.resolveCredentialEnv().catch(() => ({}))) };
1318
+ const env = { ...process.env, ...depCacheEnv(this.cwd), ...this.runtimeOptions.env, ...(await this.resolveCredentialEnv().catch(credentialEnvFallback)) };
1318
1319
  const preflightError = anthropicCredentialPreflight(env);
1319
1320
  if (preflightError) {
1320
1321
  this.messages.push({ role: "user", content: hasImages ? content : prompt, timestamp: Date.now() });
@@ -1507,12 +1508,12 @@ export class ClaudeCodeRuntime {
1507
1508
  return [{ id: "anthropic", name: "Anthropic", oauth: true, models: FALLBACK_MODELS }];
1508
1509
  }
1509
1510
  async createSession(options) {
1510
- const session = new ClaudeSession(this.options, options.workspace, options.toolInterceptor, options.toolProvider);
1511
+ const session = new ClaudeSession(await withSessionCredentials(this.options, options.credentialLabels), options.workspace, options.toolInterceptor, options.toolProvider);
1511
1512
  this.sessions.push(session);
1512
1513
  return { session };
1513
1514
  }
1514
1515
  async openSession(options) {
1515
- const session = new ClaudeSession(this.options, options.workspace, options.toolInterceptor, options.toolProvider, options.sessionFile);
1516
+ const session = new ClaudeSession(await withSessionCredentials(this.options, options.credentialLabels), options.workspace, options.toolInterceptor, options.toolProvider, options.sessionFile);
1516
1517
  this.sessions.push(session);
1517
1518
  return {
1518
1519
  session,
@@ -188,7 +188,8 @@ class PiSession {
188
188
  */
189
189
  async interactiveTuiCommand() {
190
190
  const file = this.sessionFile;
191
- if (!file)
191
+ // The shared native auth.json cannot represent a session-local assignment.
192
+ if (!file || Object.keys(this.tui.credentialLabels ?? {}).length)
192
193
  return null;
193
194
  // Pi's own TUI reads its plaintext auth.json store, so project the vault to
194
195
  // disk (refreshed) for the hand-off. Best-effort: an empty auth.json just
@@ -423,7 +424,7 @@ export class PiRuntime {
423
424
  modelsPath: path.join(piDir, "models.json"),
424
425
  allowModelNetwork,
425
426
  })
426
- : await createPiModelRuntime({ credsDir, piDir, allowModelNetwork, workspace: sessionManager.getCwd() || options.workspace });
427
+ : await createPiModelRuntime({ credsDir, piDir, allowModelNetwork, workspace: sessionManager.getCwd() || options.workspace, credentialLabels: options.credentialLabels });
427
428
  const backgroundShells = new BackgroundShellTracker();
428
429
  const createRuntime = async ({ cwd, agentDir, sessionManager, sessionStartEvent }) => {
429
430
  const sessionId = sessionManager.getSessionId();
@@ -451,6 +452,7 @@ export class PiRuntime {
451
452
  sessionManager,
452
453
  });
453
454
  const tui = {
455
+ credentialLabels: options.credentialLabels,
454
456
  credsDir,
455
457
  piDir,
456
458
  sessionsDir: this.options.sessionsDir,
@@ -0,0 +1,21 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // Wire format mirrored in src/automation-template.ts (node has no core dependency).
3
+ const PREFIX = "bivy-automation-v1\n";
4
+ /** Account labels, never credentials, travel inside the encrypted template. */
5
+ export function encodeAutomationTemplate(instructions, credentialLabels) {
6
+ return Object.keys(credentialLabels).length || instructions.startsWith(PREFIX)
7
+ ? PREFIX + JSON.stringify({ instructions, credentialLabels })
8
+ : instructions;
9
+ }
10
+ /** Legacy plaintext templates remain valid. Malformed structured templates fail closed. */
11
+ export function decodeAutomationTemplate(value) {
12
+ if (!value.startsWith(PREFIX))
13
+ return { instructions: value, credentialLabels: {} };
14
+ const data = JSON.parse(value.slice(PREFIX.length));
15
+ if (!data || typeof data.instructions !== "string" || !data.credentialLabels ||
16
+ typeof data.credentialLabels !== "object" || Array.isArray(data.credentialLabels) ||
17
+ Object.entries(data.credentialLabels).some(([provider, label]) => !provider.trim() || provider !== provider.trim().toLowerCase() || typeof label !== "string" || !label.trim())) {
18
+ throw new Error("Invalid automation account selections");
19
+ }
20
+ return { instructions: data.instructions, credentialLabels: data.credentialLabels };
21
+ }
@@ -14,6 +14,7 @@ import { createCredentialVault } from "./store.js";
14
14
  import { selectCredential } from "./selection.js";
15
15
  export { projectIdsFromWorkspace } from "./selection.js";
16
16
  import { loadPresets, defaultPresetsPath } from "./presets.js";
17
+ import { credentialEnvFallback } from "./session.js";
17
18
  /** Refresh an OAuth token this many ms before it expires (clock-skew guard). */
18
19
  const OAUTH_REFRESH_SKEW_MS = 60_000;
19
20
  /** Resolver over Bivy's credential store, with OAuth refresh-on-read via the bridge. */
@@ -166,11 +167,20 @@ export async function buildAgentCredentialEnv(store, providers, activeProvider,
166
167
  try {
167
168
  cred = await store.getCredential(id, workspace ? { workspace } : undefined);
168
169
  }
169
- catch {
170
+ catch (error) {
171
+ credentialEnvFallback(error);
170
172
  continue;
171
173
  }
172
174
  if (!cred)
173
175
  continue;
176
+ // Session-pinned Anthropic auth clears the competing ambient login even
177
+ // when the agent has not advertised its active model provider yet.
178
+ if (cred.provider === "anthropic" && cred.env) {
179
+ for (const key of ["ANTHROPIC_API_KEY", "CLAUDE_CODE_OAUTH_TOKEN"]) {
180
+ if (cred.env[key] === "")
181
+ env[key] = "";
182
+ }
183
+ }
174
184
  const isActive = !!active && cred.provider === active;
175
185
  if (cred.kind === "oauth") {
176
186
  // OAuth *subscription* tokens are provider-specific and are not accepted
@@ -1,11 +1,20 @@
1
1
  import { defaultPresetsPath, loadPresets } from "./presets.js";
2
2
  import { selectCredential } from "./selection.js";
3
+ import { CredentialSelectionError } from "./session.js";
3
4
  /** A provider-addressed view of the vault for consumers without labeled accounts.
4
5
  * Resolve on every operation; never copy a work credential into the default slot.
5
6
  * Refresh writes stay attached to the selected record's label and metadata.
6
7
  */
7
8
  export function selectedCredentialStore(store, credsDir, context) {
8
- const select = async (provider) => selectCredential(provider, await store.listRecords(), loadPresets(defaultPresetsPath(credsDir)), context)?.record;
9
+ const selectRecord = (provider, records, presets) => {
10
+ const record = selectCredential(provider, records, presets, context)?.record;
11
+ const label = context?.credentialLabels?.[provider];
12
+ if (label && (!record || record.source.kind !== "stored")) {
13
+ throw new CredentialSelectionError(`Selected account “${label}” for ${provider} is unavailable on this machine`);
14
+ }
15
+ return record;
16
+ };
17
+ const select = async (provider) => selectRecord(provider, await store.listRecords(), loadPresets(defaultPresetsPath(credsDir)));
9
18
  return {
10
19
  async read(provider) {
11
20
  const record = await select(provider);
@@ -17,8 +26,8 @@ export function selectedCredentialStore(store, credsDir, context) {
17
26
  async list() {
18
27
  const records = await store.listRecords();
19
28
  const presets = loadPresets(defaultPresetsPath(credsDir));
20
- return [...new Set(records.map((r) => r.provider))].flatMap((providerId) => {
21
- const record = selectCredential(providerId, records, presets, context)?.record;
29
+ return [...new Set([...records.map((r) => r.provider), ...Object.keys(context?.credentialLabels ?? {})])].flatMap((providerId) => {
30
+ const record = selectRecord(providerId, records, presets);
22
31
  if (record?.source.kind !== "stored")
23
32
  return [];
24
33
  const credential = record.source.cred;
@@ -17,8 +17,9 @@ export function selectCredential(provider, records, presets, context) {
17
17
  const workspace = context?.workspace?.trim();
18
18
  const projects = [context?.project?.trim(), ...(workspace ? projectIdsFromWorkspace(workspace) : [])].filter(Boolean);
19
19
  const projectPreset = projects.map((value) => `project:${value}`).find((name) => presets.presets?.[name]?.[id]);
20
+ const preferLabel = context?.credentialLabels?.[id] ?? context?.preferLabel;
20
21
  return resolveCredential(id, records, presets, {
21
22
  ...(projectPreset ? { preset: projectPreset } : {}),
22
- ...(context?.preferLabel ? { preferLabel: context.preferLabel } : {}),
23
+ ...(preferLabel ? { preferLabel } : {}),
23
24
  });
24
25
  }
@@ -0,0 +1,48 @@
1
+ export class CredentialSelectionError extends Error {
2
+ }
3
+ /** Preserve historical best-effort auth only when no explicit account failed. */
4
+ export function credentialEnvFallback(error) {
5
+ if (error instanceof CredentialSelectionError)
6
+ throw error;
7
+ return {};
8
+ }
9
+ /** Bind a private provider→label map, without changing shared vault assignments. */
10
+ export async function withSessionCredentials(options, labels) {
11
+ if (!labels || !Object.keys(labels).length)
12
+ return options;
13
+ const store = options.credentials;
14
+ if (!store)
15
+ throw new CredentialSelectionError("This agent manages its own login and cannot use automation account overrides");
16
+ const selected = { ...labels };
17
+ const credentials = {
18
+ listConfigured: async () => [...new Set([...(await store.listConfigured?.().catch(() => []) ?? []), ...Object.keys(selected)])],
19
+ async getCredential(provider, context) {
20
+ const label = selected[provider.trim().toLowerCase()];
21
+ let credential;
22
+ try {
23
+ credential = await store.getCredential(provider, { ...context, ...(label ? { preferLabel: label } : {}) });
24
+ }
25
+ catch (error) {
26
+ if (label)
27
+ throw new CredentialSelectionError(`Could not read selected account “${label}” for ${provider}`);
28
+ throw error;
29
+ }
30
+ if (label && !credential)
31
+ throw new CredentialSelectionError(`Selected account “${label}” for ${provider} is unavailable on this machine`);
32
+ if (label && credential?.kind === "oauth" && credential.provider !== "anthropic") {
33
+ throw new CredentialSelectionError(`This agent cannot use a ${provider} subscription override; use an API key or a Bivy-managed model agent`);
34
+ }
35
+ // Do not let an inherited alternative Anthropic login outrank the pin.
36
+ if (label && credential?.provider === "anthropic")
37
+ return {
38
+ ...credential,
39
+ env: { ...credential.env, [credential.kind === "oauth" ? "ANTHROPIC_API_KEY" : "CLAUDE_CODE_OAUTH_TOKEN"]: "" },
40
+ };
41
+ return credential;
42
+ },
43
+ };
44
+ // Fail before starting a subprocess, including overrides for a second provider.
45
+ for (const provider of Object.keys(selected))
46
+ await credentials.getCredential(provider);
47
+ return { ...options, credentials };
48
+ }
@@ -135,8 +135,8 @@ export class AgentService {
135
135
  const toolProvider = message.options.toolSpecs?.length ? this.makeToolProvider(svc, message.options.toolSpecs) : undefined;
136
136
  const workspace = message.options.workspace ?? process.cwd();
137
137
  const result = message.op === "open" && message.options.sessionFile
138
- ? await runtime.openSession({ workspace, sessionFile: message.options.sessionFile, toolInterceptor, toolProvider })
139
- : await runtime.createSession({ workspace, toolInterceptor, toolProvider });
138
+ ? await runtime.openSession({ workspace, credentialLabels: message.options.credentialLabels, sessionFile: message.options.sessionFile, toolInterceptor, toolProvider })
139
+ : await runtime.createSession({ workspace, credentialLabels: message.options.credentialLabels, toolInterceptor, toolProvider });
140
140
  svc.session = result.session;
141
141
  svc.id = result.session.id;
142
142
  svc.lastSent = this.mirror(result.session);
@@ -26,8 +26,11 @@ export function piCredentialStore(store) {
26
26
  export async function createPiModelRuntime(opts) {
27
27
  const store = opts.store ?? createCredentialVault(opts.credsDir);
28
28
  const { ModelRuntime } = await import("@earendil-works/pi-coding-agent");
29
+ const selected = selectedCredentialStore(store, opts.credsDir, { workspace: opts.workspace, credentialLabels: opts.credentialLabels });
30
+ for (const provider of Object.keys(opts.credentialLabels ?? {}))
31
+ await selected.read(provider);
29
32
  return ModelRuntime.create({
30
- credentials: piCredentialStore(selectedCredentialStore(store, opts.credsDir, { workspace: opts.workspace })),
33
+ credentials: piCredentialStore(selected),
31
34
  modelsPath: path.join(opts.piDir, "models.json"),
32
35
  allowModelNetwork: opts.allowModelNetwork ?? false,
33
36
  });
@@ -5,6 +5,7 @@ import { randomUUID } from "node:crypto";
5
5
  import { EventEmitter } from "node:events";
6
6
  import { stripAnsi } from "./ansi.js";
7
7
  import { buildAgentCredentialEnv } from "./credentials.js";
8
+ import { withSessionCredentials, credentialEnvFallback } from "../credentials/session.js";
8
9
  import { egressEnv, sessionEgressEnv } from "../harness/egress.js";
9
10
  import { depCacheEnv } from "../harness/dep-cache.js";
10
11
  import { bivySessionEnv } from "./session-env.js";
@@ -304,7 +305,7 @@ class ProcessSession {
304
305
  // added after this session started) reach the agent. The vault wins over any
305
306
  // ambient key so Bivy's shared sign-in is authoritative.
306
307
  const credentialEnv = this.runtimeOptions.credentials
307
- ? await buildAgentCredentialEnv(this.runtimeOptions.credentials, undefined, this.currentModelProvider, this.cwd).catch(() => ({}))
308
+ ? await buildAgentCredentialEnv(this.runtimeOptions.credentials, undefined, this.currentModelProvider, this.cwd).catch(credentialEnvFallback)
308
309
  : {};
309
310
  // Optional prepare step (e.g. Codex materializes its auth.json from the vault
310
311
  // and pins CODEX_HOME). Runs after credentials, before preflight/spawn; its
@@ -530,7 +531,7 @@ export class ProcessRuntime {
530
531
  return [...byProvider.values()];
531
532
  }
532
533
  async createSession(options) {
533
- const session = new ProcessSession(this.options, options.workspace);
534
+ const session = new ProcessSession(await withSessionCredentials(this.options, options.credentialLabels), options.workspace);
534
535
  this.sessions.push(session);
535
536
  return { session, warning: "Generic CLI runtime streams stdout/stderr only; approvals, model picker, and resume depend on the underlying agent protocol." };
536
537
  }
@@ -538,7 +539,7 @@ export class ProcessRuntime {
538
539
  // Resumable runtimes bind the agent's session id so each prompt continues it
539
540
  // (see resumeArgs); non-resumable ones ignore the ref and start fresh.
540
541
  if (this.options.resumable) {
541
- const session = new ProcessSession(this.options, options.workspace, options.sessionFile);
542
+ const session = new ProcessSession(await withSessionCredentials(this.options, options.credentialLabels), options.workspace, options.sessionFile);
542
543
  this.sessions.push(session);
543
544
  return { session };
544
545
  }
@@ -4,6 +4,7 @@ import { spawn } from "node:child_process";
4
4
  import { randomUUID } from "node:crypto";
5
5
  import { EventEmitter } from "node:events";
6
6
  import { buildAgentCredentialEnv } from "./credentials.js";
7
+ import { withSessionCredentials, credentialEnvFallback } from "../credentials/session.js";
7
8
  import { bivySessionEnv } from "./session-env.js";
8
9
  import { mergeAgentCommands } from "./slash-commands.js";
9
10
  import { withExactCapabilitySurface } from "./types.js";
@@ -318,7 +319,7 @@ class ProtocolSession {
318
319
  if (!hook)
319
320
  return null;
320
321
  const credentialEnv = this.runtimeOptions.credentials
321
- ? await buildAgentCredentialEnv(this.runtimeOptions.credentials, undefined, this.currentModelProvider, this.cwd).catch(() => ({}))
322
+ ? await buildAgentCredentialEnv(this.runtimeOptions.credentials, undefined, this.currentModelProvider, this.cwd).catch(credentialEnvFallback)
322
323
  : {};
323
324
  let prepareEnv = this.prepareEnv;
324
325
  if (this.runtimeOptions.prepare) {
@@ -340,7 +341,7 @@ class ProtocolSession {
340
341
  if (this.child)
341
342
  return;
342
343
  const credentialEnv = this.runtimeOptions.credentials
343
- ? await buildAgentCredentialEnv(this.runtimeOptions.credentials, undefined, this.currentModelProvider, this.cwd).catch(() => ({}))
344
+ ? await buildAgentCredentialEnv(this.runtimeOptions.credentials, undefined, this.currentModelProvider, this.cwd).catch(credentialEnvFallback)
344
345
  : {};
345
346
  // Optional prepare step, run before the child spawns because a shim reads its
346
347
  // credential at launch (e.g. Codex mints ~/.codex/auth.json from the vault and
@@ -440,7 +441,7 @@ class ProtocolSession {
440
441
  if (!this.runtimeOptions.prepare && !this.runtimeOptions.preflight)
441
442
  return undefined;
442
443
  const credentialEnv = this.runtimeOptions.credentials
443
- ? await buildAgentCredentialEnv(this.runtimeOptions.credentials, undefined, this.currentModelProvider, this.cwd).catch(() => ({}))
444
+ ? await buildAgentCredentialEnv(this.runtimeOptions.credentials, undefined, this.currentModelProvider, this.cwd).catch(credentialEnvFallback)
444
445
  : {};
445
446
  if (this.runtimeOptions.prepare) {
446
447
  this.prepareEnv =
@@ -993,7 +994,7 @@ export class ProtocolRuntime {
993
994
  return this.options.catalog ?? [];
994
995
  }
995
996
  async createSession(options) {
996
- const session = new ProtocolSession(this.options, options.workspace, this.capabilities, options.toolInterceptor);
997
+ const session = new ProtocolSession(await withSessionCredentials(this.options, options.credentialLabels), options.workspace, this.capabilities, options.toolInterceptor);
997
998
  try {
998
999
  await session.start();
999
1000
  this.sessions.push(session);
@@ -1010,7 +1011,7 @@ export class ProtocolRuntime {
1010
1011
  // Adopt the caller's canonical id (a reopen of a known session) so the
1011
1012
  // resumed session keeps its original id instead of taking `sessionFile` (the
1012
1013
  // agent's own ref) as its id — see OpenSessionOptions.canonicalId.
1013
- const session = new ProtocolSession(this.options, options.workspace, this.capabilities, options.toolInterceptor, options.sessionFile, options.canonicalId);
1014
+ const session = new ProtocolSession(await withSessionCredentials(this.options, options.credentialLabels), options.workspace, this.capabilities, options.toolInterceptor, options.sessionFile, options.canonicalId);
1014
1015
  try {
1015
1016
  await session.start();
1016
1017
  if (!this.options.resumable && !this.capabilities.resume) {
@@ -543,12 +543,12 @@ export class RemoteRuntime {
543
543
  async createSession(options) {
544
544
  const transport = await this.config.connect();
545
545
  const toolSpecs = options.toolProvider?.list();
546
- return startRemoteSession(transport, { runtime: this.id, sandbox: this.config.sandbox, op: "create", options: { workspace: options.workspace, hasToolInterceptor: Boolean(options.toolInterceptor), ...(toolSpecs?.length ? { toolSpecs } : {}) } }, { toolInterceptor: options.toolInterceptor, toolProvider: options.toolProvider });
546
+ return startRemoteSession(transport, { runtime: this.id, sandbox: this.config.sandbox, op: "create", options: { credentialLabels: options.credentialLabels, workspace: options.workspace, hasToolInterceptor: Boolean(options.toolInterceptor), ...(toolSpecs?.length ? { toolSpecs } : {}) } }, { toolInterceptor: options.toolInterceptor, toolProvider: options.toolProvider });
547
547
  }
548
548
  async openSession(options) {
549
549
  const transport = await this.config.connect();
550
550
  const toolSpecs = options.toolProvider?.list();
551
- return startRemoteSession(transport, { runtime: this.id, sandbox: this.config.sandbox, op: "open", options: { workspace: options.workspace, sessionFile: options.sessionFile, hasToolInterceptor: Boolean(options.toolInterceptor), ...(toolSpecs?.length ? { toolSpecs } : {}) } }, { toolInterceptor: options.toolInterceptor, toolProvider: options.toolProvider });
551
+ return startRemoteSession(transport, { runtime: this.id, sandbox: this.config.sandbox, op: "open", options: { credentialLabels: options.credentialLabels, workspace: options.workspace, sessionFile: options.sessionFile, hasToolInterceptor: Boolean(options.toolInterceptor), ...(toolSpecs?.length ? { toolSpecs } : {}) } }, { toolInterceptor: options.toolInterceptor, toolProvider: options.toolProvider });
552
552
  }
553
553
  /**
554
554
  * Re-attach to a session already live on the agent service (Stage 2 routing).
package/dist/server.js CHANGED
@@ -36,6 +36,7 @@ import { InMemoryLocationRegistry } from "./runtime/location-registry.js";
36
36
  import { ControlPlaneSessionLocationRegistry, LayeredSessionLocationRegistry } from "./runtime/control-plane-location.js";
37
37
  import { attachAdoptedSessions, classifyAttachFailure } from "./runtime/adoption.js";
38
38
  import { createCredentialStore, testProviderCredential } from "./runtime/credentials.js";
39
+ import { decodeAutomationTemplate } from "./automation-template.js";
39
40
  import { isModelAuthError, authProviderForSession, classifyModelAuthError } from "./runtime/auth-errors.js";
40
41
  import { createCredentialVault, migrateVaultDir } from "./runtime/credential-store.js";
41
42
  import { probeAnthropicAccess } from "./runtime/anthropic-preflight.js";
@@ -4136,6 +4137,7 @@ async function runIssueTaskInner(cfg, issue, source, overrides = {}) {
4136
4137
  // which now adopts the existing remote branch rather than colliding.
4137
4138
  const existing = findIssueSession(source);
4138
4139
  if (existing?.worktree && fs.existsSync(existing.worktree.path)) {
4140
+ assertSessionAccounts(existing, overrides.credentialLabels);
4139
4141
  const currentSandbox = existing.sandbox ?? sandboxTier();
4140
4142
  const safety = projectSafety(existing.worktree.path, overrides.sandbox ?? currentSandbox, overrides.approvalMode);
4141
4143
  if (safety.sandbox !== currentSandbox) {
@@ -4208,6 +4210,7 @@ async function runIssueTaskInner(cfg, issue, source, overrides = {}) {
4208
4210
  runtimeId: directives.runtimeId,
4209
4211
  sandbox: safety.sandbox,
4210
4212
  approvalMode: safety.approval,
4213
+ credentialLabels: overrides.credentialLabels,
4211
4214
  });
4212
4215
  record.githubIssueUrl = `https://github.com/${cfg.owner}/${cfg.repo}/issues/${issue.number}`;
4213
4216
  // Title the session from the issue up front so it never shows as "Untitled
@@ -4805,6 +4808,11 @@ function linearSessionSource(externalId) {
4805
4808
  * starting, and return false so the caller falls through. Returns true only when
4806
4809
  * it fully handled the item.
4807
4810
  */
4811
+ function assertSessionAccounts(record, labels) {
4812
+ if (Object.entries(labels ?? {}).some(([provider, label]) => record.credentialLabels?.[provider] !== label)) {
4813
+ throw new Error("This session uses different provider accounts; start a new session to use the automation's selections");
4814
+ }
4815
+ }
4808
4816
  async function continueCorrelatedSession(item, prompt, report, opts) {
4809
4817
  if (item.targetKind !== "existing_session" || !item.targetSessionId)
4810
4818
  return false;
@@ -4831,6 +4839,7 @@ async function continueCorrelatedSession(item, prompt, report, opts) {
4831
4839
  }
4832
4840
  return false;
4833
4841
  }
4842
+ assertSessionAccounts(record, opts?.credentialLabels);
4834
4843
  const branch = record.worktree?.branch;
4835
4844
  if (opts?.resumeOnMissing) {
4836
4845
  // Durable work targeting an existing Session waits for its current turn to
@@ -4908,13 +4917,16 @@ async function executeWorkItem(item, report, signal) {
4908
4917
  // assigned node can read. The envelope prefix is Bivy's own and never appears
4909
4918
  // on issue/Slack/Linear bodies, so decrypt whenever it's present regardless of
4910
4919
  // source.
4920
+ let credentialLabels;
4911
4921
  if (item.body?.startsWith("bivy-room-v1:")) {
4912
4922
  const [, nodeId, ...payload] = item.body.split(":");
4913
4923
  if (nodeId !== identity.nodeId || payload.length === 0) {
4914
4924
  throw new Error("automation instructions were encrypted for a different node");
4915
4925
  }
4916
4926
  try {
4917
- item = { ...item, body: open(pairingStore.roomKey(), payload.join(":")) };
4927
+ const template = decodeAutomationTemplate(open(pairingStore.roomKey(), payload.join(":")));
4928
+ credentialLabels = template.credentialLabels;
4929
+ item = { ...item, body: template.instructions };
4918
4930
  }
4919
4931
  catch {
4920
4932
  throw new Error("could not decrypt automation instructions on this node");
@@ -4983,6 +4995,7 @@ async function executeWorkItem(item, report, signal) {
4983
4995
  await runIssueTask(cfg, issue, {
4984
4996
  runtimeId: item.runtimeId,
4985
4997
  model: item.model,
4998
+ credentialLabels,
4986
4999
  sandbox: normalizeSandboxTier(item.sandbox),
4987
5000
  approvalMode: approvalModeFrom(item.approvalMode),
4988
5001
  onEvidence: report,
@@ -5007,7 +5020,7 @@ async function executeWorkItem(item, report, signal) {
5007
5020
  throw new Error(`Linear work item has an invalid repo "${repoSlug}"`);
5008
5021
  // Case B: a re-dispatch the control plane correlated to an existing session
5009
5022
  // continues it as a normal chat instead of starting cold (mirrors GitHub).
5010
- if (await continueCorrelatedSession(item, buildLinearTaskPrompt(issue, item.body), report, { resumeOnMissing: item.targetKind === "existing_session", signal }))
5023
+ if (await continueCorrelatedSession(item, buildLinearTaskPrompt(issue, item.body), report, { resumeOnMissing: item.targetKind === "existing_session", signal, credentialLabels }))
5011
5024
  return;
5012
5025
  const githubToken = await resolveGitHubToken();
5013
5026
  if (!githubToken)
@@ -5023,6 +5036,7 @@ async function executeWorkItem(item, report, signal) {
5023
5036
  makeActive: false,
5024
5037
  source: linearSessionSource(item.externalId),
5025
5038
  runtimeId: item.runtimeId || nodeConfiguredDefaultAgent(),
5039
+ credentialLabels,
5026
5040
  sandbox: safety.sandbox,
5027
5041
  approvalMode: safety.approval,
5028
5042
  });
@@ -5068,7 +5082,7 @@ async function executeWorkItem(item, report, signal) {
5068
5082
  // Scheduled runs targeting an existing session are STRICT: the message must
5069
5083
  // land in that session (resumed from disk if needed), never silently in a new
5070
5084
  // one — so a session that can't be resumed fails the run instead.
5071
- if (await continueCorrelatedSession(item, request, report, { resumeOnMissing: item.source === "schedule" || item.targetKind === "existing_session", isMessage, signal }))
5085
+ if (await continueCorrelatedSession(item, request, report, { resumeOnMissing: item.source === "schedule" || item.targetKind === "existing_session", isMessage, signal, credentialLabels }))
5072
5086
  return;
5073
5087
  const requestedSandbox = normalizeSandboxTier(item.sandbox);
5074
5088
  // Prepare an explicit repository before resolving its policy. Otherwise a
@@ -5084,6 +5098,7 @@ async function executeWorkItem(item, report, signal) {
5084
5098
  const sessionOpts = {
5085
5099
  makeActive: false,
5086
5100
  title: item.title,
5101
+ credentialLabels,
5087
5102
  runtimeId: item.runtimeId,
5088
5103
  sandbox,
5089
5104
  };
@@ -6303,6 +6318,7 @@ function persistSessionMetadata(record, status = sessionStatus(record)) {
6303
6318
  delegationDepth: record.delegationDepth,
6304
6319
  runtimeId: record.runtimeId,
6305
6320
  sandbox: record.sandbox,
6321
+ credentialLabels: record.credentialLabels,
6306
6322
  agentName: getRuntime(record.runtimeId).displayName,
6307
6323
  contract: record.contract,
6308
6324
  status,
@@ -7570,7 +7586,7 @@ async function refreshRecordAfterTui(record) {
7570
7586
  const workspace = record.worktree?.path || oldSession.cwd || record.workspace;
7571
7587
  // Refreshing an EXISTING record: its id is already known, so attach_to_chat
7572
7588
  // (see toolProvider's SessionIdRef doc) can be wired live, not deferred.
7573
- const runtimeSessionOptions = { workspace, toolProvider: integrations.toolProvider({ current: record.id }), ...(rt.capabilities.toolInterception ? { toolInterceptor: guardianInterceptor } : {}) };
7589
+ const runtimeSessionOptions = { credentialLabels: record.credentialLabels, workspace, toolProvider: integrations.toolProvider({ current: record.id }), ...(rt.capabilities.toolInterception ? { toolInterceptor: guardianInterceptor } : {}) };
7574
7590
  const { session, warning } = await runtimeHost.openSession(rt, { ...runtimeSessionOptions, sessionFile: record.sessionFile });
7575
7591
  record.session = session;
7576
7592
  record.sessionFile = session.sessionFile ?? record.sessionFile;
@@ -7805,6 +7821,7 @@ async function recoverRecordAfterAbort(record) {
7805
7821
  const workspace = record.worktree?.path || oldSession.cwd || record.workspace;
7806
7822
  const runtimeSessionOptions = {
7807
7823
  workspace,
7824
+ credentialLabels: record.credentialLabels,
7808
7825
  toolProvider: integrations.toolProvider({ current: record.id }),
7809
7826
  ...(rt.capabilities.toolInterception ? { toolInterceptor: guardianInterceptor } : {}),
7810
7827
  };
@@ -7904,6 +7921,7 @@ async function createSession(workspace = defaultWorkspace, sessionFile, opts = {
7904
7921
  const restoredWorktree = requestedSessionFile ? restoredWorktreeFromMetadata(storedMeta) : undefined;
7905
7922
  const existing = requestedSessionFile ? (openSessions.get(requestedSessionFile) ?? (storedMeta?.id ? openSessions.get(storedMeta.id) : undefined)) : undefined;
7906
7923
  if (existing) {
7924
+ assertSessionAccounts(existing, opts.credentialLabels);
7907
7925
  // Reopening an already-open session must NOT bump its last-active time —
7908
7926
  // that only tracks real user/agent activity, not focus. (Was touchSession.)
7909
7927
  if (makeActive)
@@ -7993,7 +8011,8 @@ async function createSession(workspace = defaultWorkspace, sessionFile, opts = {
7993
8011
  // built now, up front — so hand it this box instead of a session id and fill
7994
8012
  // `.current` in the moment `sessionId` is (see toolProvider's SessionIdRef doc).
7995
8013
  const attachSessionIdRef = {};
7996
- const runtimeSessionOptions = { workspace: runtimeWorkspace, toolProvider: integrations.toolProvider(attachSessionIdRef), ...(rt.capabilities.toolInterception ? { toolInterceptor: guardianInterceptor } : {}) };
8014
+ const credentialLabels = opts.credentialLabels ?? storedMeta?.credentialLabels;
8015
+ const runtimeSessionOptions = { credentialLabels, workspace: runtimeWorkspace, toolProvider: integrations.toolProvider(attachSessionIdRef), ...(rt.capabilities.toolInterception ? { toolInterceptor: guardianInterceptor } : {}) };
7997
8016
  // Stage 2/3: prefer re-attaching to a still-live remote session — routed to its
7998
8017
  // OWN agent service — over re-opening a fresh copy from disk. Falls back to
7999
8018
  // open/create when nothing live is there.
@@ -8054,7 +8073,7 @@ async function createSession(workspace = defaultWorkspace, sessionFile, opts = {
8054
8073
  // Rehydrating (rather than leaving this undefined for a resumed session)
8055
8074
  // also avoids a persistSessionMetadata call later silently clobbering the
8056
8075
  // stored contract with undefined via its `{...prev, ...input}` merge.
8057
- const record = { id: sessionId, session, runtimeId: rt.id, sandbox: sessionSandbox, approvalMode: sessionSafety.approval, automationRunId: storedMeta?.automationRunId, delegationDepth: storedMeta?.delegationDepth, workspace: sessionWorkspace, sessionFile: session.sessionFile, agentServiceAddress: attachedAddress ?? rt.agentServiceAddress, worktree, source, prUrl: storedMeta?.prUrl, prs: storedMeta?.prs, contract: storedMeta?.contract, lastTouchedAt: resumedLastActive ?? Date.now(), warning: modelFallbackMessage, ephemeral: opts.ephemeral, workspaceState: runGit(["status", "--porcelain", "--untracked-files=normal"], sessionWorkspace) ? "dirty" : "clean" };
8076
+ const record = { id: sessionId, session, runtimeId: rt.id, credentialLabels, sandbox: sessionSandbox, approvalMode: sessionSafety.approval, automationRunId: storedMeta?.automationRunId, delegationDepth: storedMeta?.delegationDepth, workspace: sessionWorkspace, sessionFile: session.sessionFile, agentServiceAddress: attachedAddress ?? rt.agentServiceAddress, worktree, source, prUrl: storedMeta?.prUrl, prs: storedMeta?.prs, contract: storedMeta?.contract, lastTouchedAt: resumedLastActive ?? Date.now(), warning: modelFallbackMessage, ephemeral: opts.ephemeral, workspaceState: runGit(["status", "--porcelain", "--untracked-files=normal"], sessionWorkspace) ? "dirty" : "clean" };
8058
8077
  // Migration: a session resumed/reopened from before this feature (or from a
8059
8078
  // node that predates it) has no stored contract. Stamp an honest one now
8060
8079
  // from currently-observed facts rather than leaving it blank forever or
@@ -8371,7 +8390,7 @@ async function createWorkspaceSession(workspace, opts = {}) {
8371
8390
  await fetchOrigin(workspace);
8372
8391
  return createGitWorkspaceSession(workspace, parsed, opts);
8373
8392
  }
8374
- const record = await createSession(workspace, undefined, { runtimeId: opts.runtimeId, sandbox: opts.sandbox, makeActive: opts.makeActive });
8393
+ const record = await createSession(workspace, undefined, { runtimeId: opts.runtimeId, credentialLabels: opts.credentialLabels, sandbox: opts.sandbox, makeActive: opts.makeActive });
8375
8394
  applyInitialSessionName(record, opts);
8376
8395
  return record;
8377
8396
  }
@@ -8389,6 +8408,7 @@ async function createGitWorkspaceSession(repoDir, parsed, opts = {}) {
8389
8408
  worktree: { branch, base },
8390
8409
  source: `repo:${parsed.slug}`,
8391
8410
  runtimeId: opts.runtimeId,
8411
+ credentialLabels: opts.credentialLabels,
8392
8412
  sandbox: opts.sandbox,
8393
8413
  makeActive: opts.makeActive,
8394
8414
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bivy/bivy",
3
- "version": "0.16.18-staging.8",
3
+ "version": "0.16.18",
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.",