@runuai/host 0.9.170 → 0.9.172

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.
@@ -1,4 +1,10 @@
1
+ import { isOwnedInternalConnectionRuntime } from "./internal-connections";
2
+ import { registerConnectionSecret } from "./connection-secret-redaction";
3
+ import { buildCustomConnectionLaunch, tomlString, connectionNativeBlanks, connectionHomePreparation, newConnectionHome, connectionDnsLookupArgv, validateConnectionAddresses } from "./custom-connection-launch";
4
+ import { connectionFailureReason, ConnectionEventSanitizer } from "./custom-connection-runtime";
5
+ import { customConnectionEnabled, type CustomConnectionMaterial, type CustomConnectionLease, type CustomConnectionReason } from "../src/custom-connections";
1
6
  import { randomUUID } from "node:crypto";
7
+ import { rememberCredentialCleanupPath } from "./credential-cleanup-inventory";
2
8
  import { AssignmentEvidenceJournal } from "./assignment-evidence";
3
9
  import { durableAgentSessionId } from "./agents/transport";
4
10
  import { agentOutputSource } from "./agents/output-source";
@@ -46,6 +52,7 @@ import { realAgentFactory } from "./agents/factory";
46
52
  import { resolveAgentMode } from "./agents/mode";
47
53
  import {
48
54
  clearPersistedCodexThread,
55
+ purgeInternalAgentSessionState,
49
56
  persistedAgentIds,
50
57
  stopPersistedAgentSession,
51
58
  } from "./agents/session-state";
@@ -633,7 +640,7 @@ function isEngineUsageKind(kind: string): kind is EngineUsageFrame["engine"] {
633
640
  * or, for the Managed Engine, the task's open run alone (its agent's task
634
641
  * token is its credential). */
635
642
  type LeasedIdentity =
636
- | { subject: "account"; runEpoch: number; credentialId: string; revision: string; version: number }
643
+ | { subject: "account"; runEpoch: number; credentialId: string; revision: string; version: number; connection?: boolean; settingsHash?: string; internal?: boolean }
637
644
  | { subject: "task-token"; runEpoch: number };
638
645
 
639
646
  type SpawnArgs = Omit<Parameters<AgentSessionFactory["create"]>[0], "environment">;
@@ -691,7 +698,7 @@ function sameLeasedIdentity(a: LeasedIdentity, b: LeasedIdentity): boolean {
691
698
  a.runEpoch === b.runEpoch &&
692
699
  a.credentialId === b.credentialId &&
693
700
  a.revision === b.revision &&
694
- a.version === b.version
701
+ a.version === b.version && a.settingsHash === b.settingsHash && a.internal === b.internal
695
702
  );
696
703
  }
697
704
  return a.subject === b.subject && a.runEpoch === b.runEpoch;
@@ -815,6 +822,15 @@ export interface OrchestratorTaskStopResult {
815
822
  }
816
823
 
817
824
  export class Orchestrator {
825
+ private readonly connectionSessions = new WeakMap<AgentSession, { material: CustomConnectionMaterial; metadata: CustomConnectionLease; identity: Extract<LeasedIdentity, { subject: "account" }> }>();
826
+ private readonly connectionSecretReleases = new WeakMap<AgentSession, () => void>();
827
+ private readonly connectionRetries = new WeakMap<Channel, Map<string, {
828
+ timer: ReturnType<typeof setTimeout> | null;
829
+ identity: LeasedIdentity;
830
+ agent: RosterAgent;
831
+ }>>();
832
+ private readonly connectionExpiryTimers = new WeakMap<AgentSession, ReturnType<typeof setTimeout>>();
833
+ private readonly sessionIdentities = new WeakMap<AgentSession, LeasedIdentity>();
818
834
  private readonly sessionEventHandlers = new WeakMap<AgentSession, AgentEventHandler>();
819
835
  private readonly channels = new Map<string, Channel>();
820
836
  private readonly channelSpecs = new Map<string, ChannelEnsureInput>();
@@ -1073,6 +1089,7 @@ export class Orchestrator {
1073
1089
  // took effect: writeAgentCli kept seeing the old empty list). Replacing the
1074
1090
  // array exactly also makes absence authoritative for live removal.
1075
1091
  channel.roster = [...spec.agents];
1092
+ this.cancelStaleConnectionRetries(channel);
1076
1093
  for (const agent of added) {
1077
1094
  // Per-agent link/document skills, like getOrCreateChannel does at
1078
1095
  // channel birth. Best-effort.
@@ -1213,6 +1230,7 @@ export class Orchestrator {
1213
1230
  channel: Channel,
1214
1231
  agentId: string,
1215
1232
  ): Promise<void> {
1233
+ this.cancelConnectionRetries(channel, agentId);
1216
1234
  const session = channel.sessions.get(agentId);
1217
1235
  const inFlightCreate = channel.factoryOperationByAgent.get(agentId);
1218
1236
  const lifecycleAtRemoval = channel.rosterSessionChanges.get(agentId);
@@ -1775,7 +1793,7 @@ export class Orchestrator {
1775
1793
  return lease.accounts
1776
1794
  .filter(
1777
1795
  (account): account is LeasedEngineAccount =>
1778
- account.engineKind === kind && isLeasedEngineAccount(account),
1796
+ account.engineKind === kind && account.authKind !== "custom-connection" && isLeasedEngineAccount(account),
1779
1797
  )
1780
1798
  .map((account) => ({
1781
1799
  id: account.credentialId,
@@ -1783,7 +1801,7 @@ export class Orchestrator {
1783
1801
  label: account.label,
1784
1802
  isDefault: false,
1785
1803
  scope: account.scope,
1786
- authKind: account.authKind,
1804
+ authKind: account.authKind as "env" | "config-dir",
1787
1805
  execEnv: {},
1788
1806
  }));
1789
1807
  }
@@ -2886,12 +2904,41 @@ export class Orchestrator {
2886
2904
  );
2887
2905
  }
2888
2906
 
2907
+ private armConnectionExpiry(channel: Channel, agentId: string, session: AgentSession): void {
2908
+ const expiresAt = this.connectionSessions.get(session)?.material.expiresAt;
2909
+ if (expiresAt == null) return;
2910
+ const check = (): void => {
2911
+ this.connectionExpiryTimers.delete(session);
2912
+ if (!this.isActiveChannel(channel) || channel.sessions.get(agentId) !== session) return;
2913
+ const remaining = expiresAt - Date.now();
2914
+ if (remaining <= 0) { this.expireConnectionSession(channel, agentId, session); return; }
2915
+ const timer = setTimeout(check, Math.min(remaining, 2_147_483_647));
2916
+ timer.unref?.();
2917
+ this.connectionExpiryTimers.set(session, timer);
2918
+ };
2919
+ check();
2920
+ }
2921
+
2922
+ private expireConnectionSession(channel: Channel, agentId: string, session: AgentSession): void {
2923
+ if (channel.sessions.get(agentId) !== session) return;
2924
+ const timer = this.connectionExpiryTimers.get(session);
2925
+ if (timer) clearTimeout(timer);
2926
+ this.connectionExpiryTimers.delete(session);
2927
+ this.retainRetiredTurnBoundary(channel, agentId, this.needsAbortedTurnBoundary(channel, agentId));
2928
+ this.endLeasedLaunches(channel, agentId, session);
2929
+ this.emitRetiredTurnAbort(channel, agentId);
2930
+ channel.sessions.delete(agentId);
2931
+ this.leasedUnavailable(channel, agentId, "expired");
2932
+ this.trackEventOperation(channel, session.close().catch(() => {}));
2933
+ }
2934
+
2889
2935
  private bindSession(
2890
2936
  channel: Channel,
2891
2937
  agentId: string,
2892
2938
  session: AgentSession,
2893
2939
  ): void {
2894
2940
  channel.sessions.set(agentId, session);
2941
+ this.armConnectionExpiry(channel, agentId, session);
2895
2942
  channel.everBound.add(agentId);
2896
2943
  const rosterChange = channel.rosterSessionChanges.get(agentId);
2897
2944
  if (rosterChange) {
@@ -2922,7 +2969,9 @@ export class Orchestrator {
2922
2969
  // transport emission order across that await; otherwise the later boundary
2923
2970
  // can consume/reorder prompt state while the earlier error still owns it.
2924
2971
  let eventTail: Promise<void> = Promise.resolve();
2925
- const onEvent: AgentEventHandler = (event) => {
2972
+ const connection = this.connectionSessions.get(session);
2973
+ const sanitizer = connection ? new ConnectionEventSanitizer(connection.material.apiKey) : null;
2974
+ const onSanitizedEvent: AgentEventHandler = (event) => {
2926
2975
  this.markChannelActivity(channel.taskId);
2927
2976
  // Recycle/teardown deletes the old session before calling close().
2928
2977
  // Adapters deliberately emit a final exit event; without this identity
@@ -2936,6 +2985,9 @@ export class Orchestrator {
2936
2985
  // ADR-154 §6: an engine that ended launches nothing more from now,
2937
2986
  // whatever its terminal handling still awaits (the runtime's proof).
2938
2987
  if (event.type === "error" || event.type === "exit") {
2988
+ const timer = this.connectionExpiryTimers.get(session);
2989
+ if (timer) clearTimeout(timer);
2990
+ this.connectionExpiryTimers.delete(session);
2939
2991
  this.endLeasedLaunches(channel, agentId, session);
2940
2992
  }
2941
2993
  const operation = eventTail
@@ -2950,11 +3002,12 @@ export class Orchestrator {
2950
3002
  return;
2951
3003
  }
2952
3004
  await this.handleAgentEvent(channel, agentId, session, event);
3005
+ if (connection && event.type === "error") await session.close().catch(() => {});
2953
3006
  })
2954
3007
  .catch((err: unknown) => {
2955
3008
  console.warn(
2956
3009
  `[orchestrator] ${channel.taskId}/${agentId}: event handling failed: ${
2957
- err instanceof Error ? (err.stack ?? err.message) : String(err)
3010
+ connection ? connectionFailureReason(err) : err instanceof Error ? (err.stack ?? err.message) : String(err)
2958
3011
  }`,
2959
3012
  );
2960
3013
  });
@@ -2965,6 +3018,9 @@ export class Orchestrator {
2965
3018
  () => channel.eventOperations.delete(operation),
2966
3019
  );
2967
3020
  };
3021
+ const onEvent: AgentEventHandler = (event) => {
3022
+ for (const safe of sanitizer ? sanitizer.push(event) : [event]) onSanitizedEvent(safe);
3023
+ };
2968
3024
  this.sessionEventHandlers.set(session, onEvent);
2969
3025
  session.onEvent(onEvent);
2970
3026
  }
@@ -3047,12 +3103,64 @@ export class Orchestrator {
3047
3103
  if (!this.isActiveChannel(channel)) return null;
3048
3104
  const account = this.leasedAccountFor(channel, agentId, kind, open);
3049
3105
  if (!account) {
3050
- reason = `no ${kind} account this task can use`;
3106
+ reason = args.agent.accountBinding === "required"
3107
+ ? args.agent.accountId && open.connectionsUnsupported?.includes(args.agent.accountId) ? "unsupported" : "connection removed"
3108
+ : `no ${kind} account this task can use`;
3051
3109
  break;
3052
3110
  }
3053
3111
  if (account.authKind === "env") {
3054
3112
  return this.withLeasedAccount(channel, agentId, args, account, open.assignment.runEpoch);
3055
3113
  }
3114
+ if (account.authKind === "custom-connection") {
3115
+ try {
3116
+ if (!account.connection || !account.connectionMaterial || args.agent.accountBinding !== "required") throw new Error("unsupported");
3117
+ const home = newConnectionHome(account.credentialId, account.revision, agentId, account.connection.settingsHash);
3118
+ const launch = buildCustomConnectionLaunch({
3119
+ material: account.connectionMaterial, connection: account.connection,
3120
+ model: args.agent.model, enabled: open.connectionsEnabled ?? [], home,
3121
+ runtimeProvider: environment.descriptor.locator.provider,
3122
+ });
3123
+ for (const name of ["", "auth.json", "config.toml", "aws-config", "aws-credentials"]) {
3124
+ rememberCredentialCleanupPath({
3125
+ taskId: channel.taskId, runEpoch: open.assignment.runEpoch,
3126
+ runtime: environment.descriptor.locator, lane: "engine-auth",
3127
+ path: name ? `${home}/${name}` : home, credentialId: account.credentialId,
3128
+ });
3129
+ }
3130
+ const prepared = await environment.exec({
3131
+ argv: connectionHomePreparation(home), projectEnvProfile: "maintenance",
3132
+ user: "node", timeoutMs: 20_000, maxOutputBytes: 1024,
3133
+ admit: () => {
3134
+ const current = leasedEnginesFor(channel.taskId);
3135
+ const held = current?.open && current.assignment.runEpoch === open.assignment.runEpoch &&
3136
+ current.accounts.some(a => isLeasedEngineAccount(a) &&
3137
+ a.credentialId === account.credentialId && a.revision === account.revision &&
3138
+ a.version === account.version && a.connection?.settingsHash === account.connection?.settingsHash);
3139
+ if (!this.isActiveChannel(channel) || !held || (account.connection?.internal === true && !isOwnedInternalConnectionRuntime(environment.descriptor.locator.provider))) throw new Error("unsupported");
3140
+ },
3141
+ });
3142
+ if (prepared.exitCode !== 0) throw new Error("unsupported");
3143
+ const lookup = await environment.exec({
3144
+ argv: agentCliArgv(environment, connectionDnsLookupArgv(account.connectionMaterial.baseUrl)),
3145
+ projectEnvProfile: "maintenance", user: "node", env: RUNTIME_AUTHORITY_ENV,
3146
+ timeoutMs: 10_000, maxOutputBytes: 8192,
3147
+ });
3148
+ if (lookup.exitCode !== 0) throw new Error("unreachable");
3149
+ validateConnectionAddresses(Buffer.from(lookup.stdout).toString("utf8"), account.connection.internal === true);
3150
+ if (!this.isActiveChannel(channel)) return null;
3151
+ const bound = this.withLeasedAccount(channel, agentId, {
3152
+ ...args,
3153
+ agentEnv: { ...(args.agentEnv ?? {}), ...launch.agentEnv, UAI_CUSTOM_CONNECTION: "1" },
3154
+ cliConfigOverrides: [...launch.cliConfigOverrides,
3155
+ ...(account.engineKind === "codex" ? [`model=${tomlString(launch.model)}`] : [])],
3156
+ ...(launch.claudeSettings ? { claudeSettings: launch.claudeSettings } : {}),
3157
+ }, { ...account, authKind: "env", env: launch.agentEnv }, open.assignment.runEpoch);
3158
+ // The launch compiler, not the env-account path, owns this material.
3159
+ const identity: LeasedIdentity = { subject: "account", runEpoch: open.assignment.runEpoch, credentialId: account.credentialId, revision: account.revision, version: account.version, connection: true, settingsHash: account.connection.settingsHash, internal: account.connection.internal === true };
3160
+ channel.leasedIdentityByAgent.set(agentId, identity);
3161
+ return { args: bound.args, identity };
3162
+ } catch (error) { reason = connectionFailureReason(error); break; }
3163
+ }
3056
3164
  // The account's home gets the task's managed config before anything
3057
3165
  // can start in it, whenever the gate's opening first reveals it.
3058
3166
  await this.ensureBrowserSetup(channel, kind === "codex");
@@ -3238,6 +3346,8 @@ export class Orchestrator {
3238
3346
  * launch from this moment, before its terminal handling awaits anything.
3239
3347
  * Every such handling retires or replaces the session. */
3240
3348
  private endLeasedLaunches(channel: Channel, agentId: string, session: AgentSession): void {
3349
+ this.connectionSecretReleases.get(session)?.();
3350
+ this.connectionSecretReleases.delete(session);
3241
3351
  const slot = channel.leasedLaunches.get(agentId);
3242
3352
  if (slot?.session === session) void this.retireLeasedLaunch(channel, agentId, slot);
3243
3353
  }
@@ -3255,6 +3365,7 @@ export class Orchestrator {
3255
3365
  private retireLeasedLaunch(channel: Channel, agentId: string, slot: LeasedLaunchSlot | undefined): Promise<void> {
3256
3366
  if (!slot || slot.retired) return Promise.resolve();
3257
3367
  slot.retired = true;
3368
+ if (slot.session) { this.connectionSecretReleases.get(slot.session)?.(); this.connectionSecretReleases.delete(slot.session); }
3258
3369
  if (channel.leasedLaunches.get(agentId) === slot) channel.leasedLaunches.delete(agentId);
3259
3370
  // It never wrote an admission: nothing of it is in its runtime.
3260
3371
  if (!slot.admitted) return Promise.resolve();
@@ -3406,6 +3517,10 @@ export class Orchestrator {
3406
3517
  isLeasedEngineAccount(account) &&
3407
3518
  account.revision === identity.revision &&
3408
3519
  account.version === identity.version &&
3520
+ (account.connection?.settingsHash === identity.settingsHash) &&
3521
+ ((account.connection?.internal === true) === (identity.internal === true)) &&
3522
+ (account.connection?.internal !== true || isOwnedInternalConnectionRuntime(channel.agentHandle?.descriptor.locator.provider)) &&
3523
+ (account.authKind !== "custom-connection" || ((account.expiresAt === null || account.expiresAt > Date.now()) && !!account.connectionMaterial && customConnectionEnabled(account.connectionMaterial, lease.connectionsEnabled ?? []))) &&
3409
3524
  !engineVersionHeldBack(lease.assignment, identity)
3410
3525
  );
3411
3526
  }
@@ -3436,9 +3551,14 @@ export class Orchestrator {
3436
3551
  kind: string,
3437
3552
  lease: TaskEngineLease,
3438
3553
  ): LeasedEngineAccount | undefined {
3554
+ const agent = channel.roster.find(candidate => candidate.id === agentId);
3555
+ if (agent?.accountBinding === "required") {
3556
+ return lease.accounts.find((account): account is LeasedEngineAccount => account.credentialId === agent.accountId && account.engineKind === kind && account.authKind === "custom-connection" && isLeasedEngineAccount(account));
3557
+ }
3558
+ if (lease.accounts.some(account => account.credentialId === agent?.accountId && account.authKind === "custom-connection")) return undefined;
3439
3559
  const accounts = lease.accounts.filter(
3440
3560
  (account): account is LeasedEngineAccount =>
3441
- account.engineKind === kind && isLeasedEngineAccount(account),
3561
+ account.engineKind === kind && account.authKind !== "custom-connection" && isLeasedEngineAccount(account),
3442
3562
  );
3443
3563
  const bound = channel.accountByAgent.get(agentId);
3444
3564
  const preferredId = channel.roster.find(
@@ -3456,7 +3576,7 @@ export class Orchestrator {
3456
3576
  label: account.label,
3457
3577
  isDefault: false,
3458
3578
  scope: account.scope,
3459
- authKind: account.authKind,
3579
+ authKind: account.authKind as "env" | "config-dir",
3460
3580
  execEnv: {},
3461
3581
  })),
3462
3582
  { preferredId },
@@ -3481,6 +3601,8 @@ export class Orchestrator {
3481
3601
  credentialId: account.credentialId,
3482
3602
  revision: account.revision,
3483
3603
  version: account.version,
3604
+ connection: account.authKind === "custom-connection",
3605
+ ...(account.connection ? { settingsHash: account.connection.settingsHash, internal: account.connection.internal === true } : {}),
3484
3606
  };
3485
3607
  channel.accountByAgent.set(agentId, account.credentialId);
3486
3608
  channel.leasedIdentityByAgent.set(agentId, identity);
@@ -3498,6 +3620,8 @@ export class Orchestrator {
3498
3620
  identity.credentialId,
3499
3621
  identity.revision,
3500
3622
  identity.version,
3623
+ ...(account.connection ? [account.connection.settingsHash] : []),
3624
+ ...(account.connection?.internal === true ? ["custom-internal"] : []),
3501
3625
  ]),
3502
3626
  agentEnv: {
3503
3627
  ...(args.agentEnv ?? {}),
@@ -3526,6 +3650,7 @@ export class Orchestrator {
3526
3650
  account: LeasedEngineAccount,
3527
3651
  environment: TaskEnvironmentHandle<unknown>,
3528
3652
  ): Promise<Record<string, string> | null> {
3653
+ if (account.authKind === "custom-connection") return null;
3529
3654
  const marker = {
3530
3655
  [LEASED_RUN_ENV]: String(lease.assignment.runEpoch),
3531
3656
  [LEASED_ACCOUNT_ENV]: account.credentialId,
@@ -3562,6 +3687,7 @@ export class Orchestrator {
3562
3687
  private leasedEnginesChanged(taskId: string): void {
3563
3688
  const channel = this.channels.get(taskId);
3564
3689
  if (!channel || !this.isActiveChannel(channel) || !this.isLeasedChannel(channel)) return;
3690
+ this.cancelStaleConnectionRetries(channel);
3565
3691
  if (!leasedEnginesFor(taskId)?.open) return;
3566
3692
  if (channel.leasedSyncRetry) {
3567
3693
  clearTimeout(channel.leasedSyncRetry);
@@ -3746,6 +3872,7 @@ export class Orchestrator {
3746
3872
  }
3747
3873
  }
3748
3874
  }
3875
+ await purgeInternalAgentSessionState(request, [...runtimes.values()]);
3749
3876
  // Every admission of these runs not yet used is revoked in every runtime
3750
3877
  // they were written in, or that runtime is stopped or gone and its gates
3751
3878
  // with it: no revocation or slot retirement retained for them is left to
@@ -3763,6 +3890,7 @@ export class Orchestrator {
3763
3890
  * so nothing launches on it again, its session is asked to stop, and it
3764
3891
  * starts again on what the task holds once the gate opens. */
3765
3892
  private quiesceLeasedAgent(channel: Channel, agentId: string): void {
3893
+ this.cancelConnectionRetries(channel, agentId);
3766
3894
  channel.leasedIdentityByAgent.delete(agentId);
3767
3895
  const session = channel.sessions.get(agentId);
3768
3896
  if (this.isActiveChannel(channel)) channel.leasedRespawn.add(agentId);
@@ -3932,6 +4060,8 @@ export class Orchestrator {
3932
4060
  }
3933
4061
  const onOwn = ownHold !== undefined;
3934
4062
  try {
4063
+ if (onOwn && args.agent.accountBinding === "required") { this.leasedUnavailable(channel, agentId, "unsupported"); return null; }
4064
+ args = { ...args, agentEnv: { ...connectionNativeBlanks(), UAI_CUSTOM_CONNECTION: "", ...(args.agentEnv ?? {}) } };
3935
4065
  // ADR-076 + codex-cli 0.152.0: two codex agents that resolve to the SAME
3936
4066
  // account share one CODEX_HOME, and codex's new sqlite state runtime
3937
4067
  // cannot be opened by two concurrent processes — the second agent dies
@@ -4036,7 +4166,12 @@ export class Orchestrator {
4036
4166
  void this.retireLeasedLaunch(channel, agentId, channel.leasedLaunches.get(agentId));
4037
4167
  channel.leasedLaunches.set(agentId, slot);
4038
4168
  }
4039
- const creating = this.factory.create({
4169
+ const releaseConnectionSecret = identity?.subject === "account" && identity.connection
4170
+ ? registerConnectionSecret(boundArgs.agentEnv?.UAI_CUSTOM_API_KEY || boundArgs.agentEnv?.AWS_BEARER_TOKEN_BEDROCK || boundArgs.agentEnv?.ANTHROPIC_AUTH_TOKEN || boundArgs.agentEnv?.ANTHROPIC_API_KEY || "")
4171
+ : () => {};
4172
+ let creating: Promise<AgentSession>;
4173
+ try {
4174
+ creating = this.factory.create({
4040
4175
  ...boundArgs,
4041
4176
  environment:
4042
4177
  identity && slot
@@ -4044,7 +4179,11 @@ export class Orchestrator {
4044
4179
  : onOwn
4045
4180
  ? onOwnCredentialsEnvironment(environment)
4046
4181
  : environment,
4047
- });
4182
+ });
4183
+ } catch (error) {
4184
+ releaseConnectionSecret();
4185
+ throw identity?.subject === "account" && identity.connection ? new Error(connectionFailureReason(error)) : error;
4186
+ }
4048
4187
  // Settled before this spawn's own continuation: from here until it
4049
4188
  // binds, nothing launches on it.
4050
4189
  if (slot) {
@@ -4074,14 +4213,22 @@ export class Orchestrator {
4074
4213
  // after it launched, or its session is closed unbound) is retired at
4075
4214
  // once, before anything else: a launch it handed over that has not
4076
4215
  // started in its runtime never does.
4077
- const unbound = (): Promise<void> =>
4078
- slot ? this.retireLeasedLaunch(channel, agentId, slot) : Promise.resolve();
4216
+ const unbound = (): Promise<void> => {
4217
+ releaseConnectionSecret();
4218
+ return slot ? this.retireLeasedLaunch(channel, agentId, slot) : Promise.resolve();
4219
+ };
4079
4220
  let session: AgentSession;
4080
4221
  try {
4081
4222
  session = await creating;
4082
4223
  } catch (error) {
4083
4224
  await unbound();
4084
- throw error;
4225
+ throw identity?.subject === "account" && identity.connection ? new Error(connectionFailureReason(error)) : error;
4226
+ }
4227
+ if (identity?.subject === "account" && identity.connection) this.connectionSecretReleases.set(session, releaseConnectionSecret);
4228
+ if (identity) this.sessionIdentities.set(session, { ...identity });
4229
+ if (identity?.subject === "account" && identity.connection) {
4230
+ const account = leasedEnginesFor(channel.taskId)?.accounts.find((a): a is LeasedEngineAccount => a.credentialId === identity.credentialId && a.revision === identity.revision && isLeasedEngineAccount(a));
4231
+ if (account?.connectionMaterial && account.connection) this.connectionSessions.set(session, { material: account.connectionMaterial, metadata: account.connection, identity: { ...identity } });
4085
4232
  }
4086
4233
  if (!this.isActiveChannel(channel)) {
4087
4234
  await Promise.all([unbound(), session.close().catch(() => {})]);
@@ -4178,6 +4325,7 @@ export class Orchestrator {
4178
4325
  scratchDir: string,
4179
4326
  timeoutMs: number,
4180
4327
  ): Promise<{ exitCode: number | null; stdout: string } | null> {
4328
+ if (channel.roster.find(a => a.id === agentId)?.accountBinding === "required") return null;
4181
4329
  const request = (env: Record<string, string>, admit: () => void) => ({
4182
4330
  argv: agentCliArgv(handle, argv),
4183
4331
  projectEnvProfile: "task" as const,
@@ -4205,7 +4353,7 @@ export class Orchestrator {
4205
4353
  const lease = leasedEnginesFor(channel.taskId);
4206
4354
  if (!lease?.open) return null;
4207
4355
  const account = this.leasedAccountFor(channel, agentId, kind, lease);
4208
- if (!account) return null;
4356
+ if (!account || account.authKind === "custom-connection") return null;
4209
4357
  const identity: LeasedIdentity = {
4210
4358
  subject: "account",
4211
4359
  runEpoch: lease.assignment.runEpoch,
@@ -4740,6 +4888,8 @@ export class Orchestrator {
4740
4888
  prompt: string | AcceptedPrompt,
4741
4889
  assignment?: AssignmentDelivery,
4742
4890
  ): void {
4891
+ const connection = this.connectionSessions.get(session);
4892
+ if (connection?.material.expiresAt != null && connection.material.expiresAt <= Date.now()) { this.expireConnectionSession(channel, agentId, session); return; }
4743
4893
  const replay = typeof prompt !== "string";
4744
4894
  const text = typeof prompt === "string" ? prompt : prompt.text;
4745
4895
  const leadRunId = replay ? (prompt as AcceptedPrompt).leadRunId ?? null : this.isSecretary(channel, agentId) ? channel.leadRunId ?? null : null;
@@ -4753,7 +4903,8 @@ export class Orchestrator {
4753
4903
  const begun = journal.begin({ sessionId, engine: session.kind,
4754
4904
  runEpoch: readTaskRunEpochState(channel.taskId)?.highestRunEpoch ?? 0,
4755
4905
  recipientGeneration: channel.roster.find(a => a.id === agentId)?.tokenGeneration ?? null,
4756
- runtimeVersion: evidenceRuntimeVersion, assignment: originalAssignment, replay, leadRunId });
4906
+ runtimeVersion: evidenceRuntimeVersion, assignment: originalAssignment, replay, leadRunId,
4907
+ actualAccount: (() => { const identity = this.sessionIdentities.get(session); return identity?.subject === "account" ? { credentialId: identity.credentialId, credentialRevision: identity.revision, connection: identity.connection === true } : null; })() });
4757
4908
  if (!begun) return; // stable assignment replay, already accepted
4758
4909
  turn = begun;
4759
4910
  retainedAssignment = begun.assignment;
@@ -4768,7 +4919,7 @@ export class Orchestrator {
4768
4919
  this.incrementActiveTurns(channel, agentId);
4769
4920
  void session.send(text).catch((err: unknown) => {
4770
4921
  if (turn) journal?.failed(sessionId, turn.turnId);
4771
- const diagnosticMessage = `Could not send the message to ${agentId}: ${err instanceof Error ? err.message : String(err)}`;
4922
+ const diagnosticMessage = this.connectionSessions.has(session) ? connectionFailureReason(err) : `Could not send the message to ${agentId}: ${err instanceof Error ? err.message : String(err)}`;
4772
4923
  reportAgentError(diagnosticMessage, { taskId: channel.taskId, agentId, engine: session.kind },
4773
4924
  { capture: !(err instanceof ContainerRuntimeUnavailableError) });
4774
4925
  if (channel.sessions.get(agentId) === session) {
@@ -5091,6 +5242,8 @@ export class Orchestrator {
5091
5242
  if (!this.isActiveChannel(channel)) return;
5092
5243
  switch (event.type) {
5093
5244
  case "account_usage": {
5245
+ // Connection limits come from explicit provider budgets, never CLI login probes.
5246
+ if (this.connectionSessions.has(session)) break;
5094
5247
  // Only the exact live session's cloud-leased ACCOUNT may be named on
5095
5248
  // the wire. A runner default slot and the Managed Engine's task-token
5096
5249
  // identity are deliberately unreportable.
@@ -5115,7 +5268,7 @@ export class Orchestrator {
5115
5268
  credentialId: identity.credentialId,
5116
5269
  revision: identity.revision,
5117
5270
  usage: event.usage,
5118
- leasedAccount,
5271
+ leasedAccount: leasedAccount?.authKind === "custom-connection" ? undefined : leasedAccount ? { ...leasedAccount, authKind: leasedAccount.authKind as "env" | "config-dir" } : undefined,
5119
5272
  });
5120
5273
 
5121
5274
  const coverage = event.usage.coverage;
@@ -5328,6 +5481,10 @@ export class Orchestrator {
5328
5481
  );
5329
5482
  break;
5330
5483
  }
5484
+ if (agent?.accountBinding === "required") {
5485
+ await this.recoverConnectionAgent(channel, agent, connectionFailureReason(diagnosticMessage), abortBoundaryOnRosterChange, event.resetsAt);
5486
+ break;
5487
+ }
5331
5488
  // ADR-076: a rate-limit/usage-cap error fails over to another account
5332
5489
  // for this kind (cool the current one, recreate the session on the
5333
5490
  // next, re-deliver the in-flight prompt). No-op with <2 accounts.
@@ -5508,14 +5665,16 @@ export class Orchestrator {
5508
5665
  // ledger can roll up observed spend per credential (host 0.9.167+).
5509
5666
  // Only a cloud-leased account identity is reportable; runner-default
5510
5667
  // and Managed Engine identities stay unnamed (same rule as account_usage).
5511
- const leasedIdentity = channel.leasedIdentityByAgent.get(agentId);
5668
+ const leasedIdentity = this.sessionIdentities.get(session) ?? channel.leasedIdentityByAgent.get(agentId);
5669
+ const connection = this.connectionSessions.get(session);
5670
+ const reportedUsage = connection && event.usage ? Object.fromEntries(Object.entries(event.usage).filter(([k]) => k !== "costUsd")) : event.usage;
5512
5671
  const usage =
5513
- event.usage === undefined
5672
+ reportedUsage === undefined
5514
5673
  ? undefined
5515
5674
  : {
5516
- ...event.usage,
5675
+ ...reportedUsage,
5517
5676
  ...(leasedIdentity?.subject === "account"
5518
- ? { credentialId: leasedIdentity.credentialId }
5677
+ ? { credentialId: leasedIdentity.credentialId, credentialRevision: leasedIdentity.revision, connection: leasedIdentity.connection === true }
5519
5678
  : {}),
5520
5679
  };
5521
5680
  this.emitHost({
@@ -5702,6 +5861,11 @@ export class Orchestrator {
5702
5861
  ): Promise<void> {
5703
5862
  if (!this.isActiveChannel(channel) || channel.spawning.has(agentId)) return;
5704
5863
 
5864
+ const pinned = channel.roster.find(a => a.id === agentId);
5865
+ if (pinned?.accountBinding === "required") {
5866
+ await this.recoverConnectionAgent(channel, pinned, "upstream-error", abortBoundaryOnRosterChange);
5867
+ return;
5868
+ }
5705
5869
  const tries = (channel.respawns.get(agentId) ?? 0) + 1;
5706
5870
  channel.respawns.set(agentId, tries);
5707
5871
  channel.respawnLastAt.set(agentId, Date.now());
@@ -5911,6 +6075,7 @@ export class Orchestrator {
5911
6075
  // A concurrent terminal event for the same session is already being
5912
6076
  // handled by the path that owns this spawn slot.
5913
6077
  if (channel.spawning.has(agentId)) return true;
6078
+ if (agent.accountBinding === "required") return false;
5914
6079
  if (this.engineAccounts(channel, agent.kind).length < 2) {
5915
6080
  return false;
5916
6081
  }
@@ -6133,10 +6298,11 @@ export class Orchestrator {
6133
6298
  channel: Channel,
6134
6299
  agentId: string,
6135
6300
  agent: RosterAgent,
6136
- account: ResolvedEngineAccount,
6301
+ account: ResolvedEngineAccount | null,
6137
6302
  note: string,
6138
6303
  abortBoundaryOnRosterChange = false,
6139
6304
  ): Promise<boolean> {
6305
+ if (!account && agent.accountBinding !== "required") return false;
6140
6306
  if (channel.rosterSessionChanges.has(agentId)) {
6141
6307
  this.reconcileErroredRosterChange(
6142
6308
  channel,
@@ -6225,8 +6391,10 @@ export class Orchestrator {
6225
6391
  cliSecret,
6226
6392
  this.isSecretary(channel, agent.id) ? channel.leadRunId : null,
6227
6393
  );
6228
- channel.accountByAgent.set(agentId, account.id);
6229
- noteEngineAccountUsed(account.id);
6394
+ if (account) {
6395
+ channel.accountByAgent.set(agentId, account.id);
6396
+ noteEngineAccountUsed(account.id);
6397
+ }
6230
6398
 
6231
6399
  replacement = await this.createAndBindStableSession(
6232
6400
  channel,
@@ -6243,7 +6411,7 @@ export class Orchestrator {
6243
6411
  ),
6244
6412
  agentEnv: {
6245
6413
  ...this.machineProjectEnv(task),
6246
- ...account.execEnv,
6414
+ ...(account?.execEnv ?? {}),
6247
6415
  ...base,
6248
6416
  },
6249
6417
  },
@@ -6325,6 +6493,113 @@ export class Orchestrator {
6325
6493
  return true;
6326
6494
  }
6327
6495
 
6496
+ private cancelConnectionRetries(channel: Channel, agentId?: string): void {
6497
+ const pending = this.connectionRetries.get(channel);
6498
+ if (!pending) return;
6499
+ for (const [id, entry] of pending) {
6500
+ if (agentId !== undefined && id !== agentId) continue;
6501
+ if (entry.timer) clearTimeout(entry.timer);
6502
+ pending.delete(id);
6503
+ channel.spawning.delete(id); // This timer owns the reservation.
6504
+ }
6505
+ }
6506
+
6507
+ private cancelStaleConnectionRetries(channel: Channel): void {
6508
+ const pending = this.connectionRetries.get(channel);
6509
+ if (!pending) return;
6510
+ for (const [id, entry] of pending) {
6511
+ if (this.hasDesiredLaunchSettings(channel, id, entry.agent) && this.leasedMaterialServed(channel, entry.identity)) continue;
6512
+ this.cancelConnectionRetries(channel, id);
6513
+ if (channel.roster.some(agent => agent.id === id)) channel.leasedRespawn.add(id);
6514
+ }
6515
+ }
6516
+
6517
+ private async deferConnectionRecovery(channel: Channel, agent: RosterAgent, dueAt: number): Promise<void> {
6518
+ const id = agent.id;
6519
+ const identity = channel.leasedIdentityByAgent.get(id);
6520
+ if (!identity) { this.leasedUnavailable(channel, id, "unsupported"); return; }
6521
+ channel.spawning.add(id);
6522
+ let scheduled = false;
6523
+ try {
6524
+ const old = channel.sessions.get(id);
6525
+ channel.sessions.delete(id);
6526
+ if (old) this.endLeasedLaunches(channel, id, old);
6527
+ this.retainRetiredTurnBoundary(channel, id, this.needsAbortedTurnBoundary(channel, id));
6528
+ this.retireRunnerGeneration(channel, id);
6529
+ this.emitRetiredTurnAbort(channel, id);
6530
+ this.emitSystemNote(channel.taskId, "rate-limited");
6531
+ if (old) await old.close().catch(() => {});
6532
+ if (!this.isActiveChannel(channel) || !this.hasDesiredLaunchSettings(channel, id, agent) || !this.leasedMaterialServed(channel, identity)) return;
6533
+ let pending = this.connectionRetries.get(channel);
6534
+ if (!pending) { pending = new Map(); this.connectionRetries.set(channel, pending); }
6535
+ const entry = { timer: null as ReturnType<typeof setTimeout> | null, agent, identity };
6536
+ pending.set(id, entry);
6537
+ const arm = (): void => {
6538
+ if (pending.get(id) !== entry) return;
6539
+ const remaining = dueAt - Date.now();
6540
+ if (remaining > 0) {
6541
+ entry.timer = setTimeout(arm, Math.min(remaining, 2_147_483_647));
6542
+ entry.timer.unref?.();
6543
+ return;
6544
+ }
6545
+ this.cancelConnectionRetries(channel, id);
6546
+ if (!this.isActiveChannel(channel) || !this.hasDesiredLaunchSettings(channel, id, agent) || !this.leasedMaterialServed(channel, identity)) return;
6547
+ this.trackEventOperation(channel, this.respawnAgentOnAccount(channel, id, agent, null, "rate-limited")
6548
+ .then(() => {})
6549
+ .catch(error => {
6550
+ if (this.isActiveChannel(channel) && this.hasDesiredLaunchSettings(channel, id, agent) &&
6551
+ this.leasedMaterialServed(channel, identity) && !channel.sessions.has(id)) {
6552
+ this.leasedUnavailable(channel, id, connectionFailureReason(error));
6553
+ }
6554
+ }));
6555
+ };
6556
+ scheduled = true;
6557
+ arm();
6558
+ } finally {
6559
+ if (!scheduled) {
6560
+ channel.spawning.delete(id);
6561
+ if (this.isActiveChannel(channel)) this.retryLeasedStart(channel, id);
6562
+ }
6563
+ }
6564
+ }
6565
+
6566
+ /** Retry a transport/runtime failure on exactly the same connection. Auth
6567
+ * failures stop; no native refresh, rotation or runner login is consulted. */
6568
+ private async recoverConnectionAgent(
6569
+ channel: Channel, agent: RosterAgent, reason: CustomConnectionReason,
6570
+ abortBoundaryOnRosterChange?: boolean, retryAt?: number,
6571
+ ): Promise<void> {
6572
+ const agentId = agent.id;
6573
+ if (!this.isActiveChannel(channel) || channel.spawning.has(agentId)) return;
6574
+ if (!this.hasDesiredLaunchSettings(channel, agentId, agent)) return;
6575
+ if (agent.kind !== "claude" && agent.kind !== "codex") reason = "unsupported";
6576
+ const terminal = ["unauthorized", "forbidden", "expired", "unsupported", "private-address", "model-not-found", "wrong-protocol"].includes(reason);
6577
+ const tries = (channel.respawns.get(agentId) ?? 0) + 1;
6578
+ channel.respawns.set(agentId, tries);
6579
+ channel.respawnLastAt.set(agentId, Date.now());
6580
+ const providerRetryTooLate = reason === "rate-limited" &&
6581
+ typeof retryAt === "number" && Number.isSafeInteger(retryAt) && retryAt - Date.now() > 60 * 60_000;
6582
+ if (terminal || providerRetryTooLate || tries > MAX_RESPAWNS_PER_AGENT) {
6583
+ const old = channel.sessions.get(agentId);
6584
+ if (old) this.endLeasedLaunches(channel, agentId, old);
6585
+ this.retainRetiredTurnBoundary(channel, agentId, this.needsAbortedTurnBoundary(channel, agentId));
6586
+ this.retireRunnerGeneration(channel, agentId);
6587
+ channel.sessions.delete(agentId);
6588
+ this.emitRetiredTurnAbort(channel, agentId);
6589
+ this.leasedUnavailable(channel, agentId, reason);
6590
+ if (old) await old.close().catch(() => {});
6591
+ return;
6592
+ }
6593
+ if (reason === "rate-limited") {
6594
+ const delay = Math.max(Math.min(60_000, 3000 * 2 ** (tries - 1)),
6595
+ typeof retryAt === "number" && Number.isSafeInteger(retryAt) ? retryAt - Date.now() : 0);
6596
+ await this.deferConnectionRecovery(channel, agent, Date.now() + delay);
6597
+ return;
6598
+ }
6599
+ await this.respawnAgentOnAccount(channel, agentId, agent, null, reason,
6600
+ abortBoundaryOnRosterChange ?? this.needsAbortedTurnBoundary(channel, agentId));
6601
+ }
6602
+
6328
6603
  /**
6329
6604
  * Refresh ONE agent's credential by re-spawning it on its current
6330
6605
  * (re-resolved) account — env accounts read process.env at spawn, so a
@@ -6335,8 +6610,13 @@ export class Orchestrator {
6335
6610
  channel: Channel,
6336
6611
  agent: RosterAgent,
6337
6612
  abortBoundaryOnRosterChange?: boolean,
6613
+ reason: CustomConnectionReason = "upstream-error",
6338
6614
  ): Promise<void> {
6339
6615
  const agentId = agent.id;
6616
+ if (agent.accountBinding === "required") {
6617
+ await this.recoverConnectionAgent(channel, agent, reason, abortBoundaryOnRosterChange);
6618
+ return;
6619
+ }
6340
6620
  if (!this.isActiveChannel(channel) || channel.spawning.has(agentId)) return;
6341
6621
  if (!this.hasDesiredLaunchSettings(channel, agentId, agent)) return;
6342
6622
  const abortBoundary =
@@ -6481,6 +6761,7 @@ export class Orchestrator {
6481
6761
  // start/reconcile factory continuations all check this identity, so none
6482
6762
  // can repopulate an orphan channel while its sessions close.
6483
6763
  ch.closed = true;
6764
+ this.cancelConnectionRetries(ch);
6484
6765
  this.channels.delete(taskId);
6485
6766
  // ADR-154 §6: nothing its agents' spawns were admitted for starts after:
6486
6767
  // the close ends only once every retirement of the task under way, its
@@ -6798,6 +7079,7 @@ function rosterLaunchChangeReason(
6798
7079
  if (previous.kind !== next.kind) changed.push("engine");
6799
7080
  if ((previous.model ?? "") !== (next.model ?? "")) changed.push("model");
6800
7081
  if ((previous.effort ?? "") !== (next.effort ?? "")) changed.push("effort");
7082
+ if ((previous.accountBinding ?? "preferred") !== (next.accountBinding ?? "preferred")) changed.push("binding");
6801
7083
  if ((previous.accountId ?? "") !== (next.accountId ?? "")) {
6802
7084
  changed.push("account");
6803
7085
  }