@bivy/bivy 0.16.18-staging.9 → 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.
- package/dist/agents/claude-code/runtime.js +9 -8
- package/dist/agents/pi/runtime.js +4 -2
- package/dist/automation-template.js +21 -0
- package/dist/credentials/resolver.js +11 -1
- package/dist/credentials/selected-store.js +12 -3
- package/dist/credentials/selection.js +2 -1
- package/dist/credentials/session.js +48 -0
- package/dist/runtime/agent-service.js +2 -2
- package/dist/runtime/pi-oauth.js +4 -1
- package/dist/runtime/process.js +4 -3
- package/dist/runtime/protocol.js +6 -5
- package/dist/runtime/remote.js +2 -2
- package/dist/server.js +27 -7
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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
|
|
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 =
|
|
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
|
-
...(
|
|
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);
|
package/dist/runtime/pi-oauth.js
CHANGED
|
@@ -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(
|
|
33
|
+
credentials: piCredentialStore(selected),
|
|
31
34
|
modelsPath: path.join(opts.piDir, "models.json"),
|
|
32
35
|
allowModelNetwork: opts.allowModelNetwork ?? false,
|
|
33
36
|
});
|
package/dist/runtime/process.js
CHANGED
|
@@ -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
|
}
|
package/dist/runtime/protocol.js
CHANGED
|
@@ -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) {
|
package/dist/runtime/remote.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
});
|