@runuai/host 0.9.4 → 0.9.5

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.
@@ -63,6 +63,7 @@ import {
63
63
  pickEngineAccount,
64
64
  provisionEngineAccounts,
65
65
  resolveEngineAccounts,
66
+ type ResolvedEngineAccount,
66
67
  } from "./engine-accounts";
67
68
  import { clearTaskGatewayAcl, setupMcpTaskConfig } from "./mcp-gateway";
68
69
  import { stopPreviewSidecars } from "./preview-sidecar";
@@ -231,6 +232,33 @@ const CLAUDE_CONFIG_MISSING_PATTERNS = [
231
232
  /\/\.claude\.json/i,
232
233
  ];
233
234
 
235
+ // A revoked/expired Claude credential. The token is injected as an ENV var at
236
+ // runner-spawn (passEnv CLAUDE_CODE_OAUTH_TOKEN), so a reconnect only reaches a
237
+ // running task after its agent is RE-SPAWNED. We recognise these to trigger
238
+ // that re-spawn (refreshAgentToken) instead of leaving a dead chat.
239
+ const CLAUDE_AUTH_REVOKED_PATTERNS = [
240
+ /token (was )?(revoked|expired)/i,
241
+ /(revoked|expired)[^.]*\btoken/i,
242
+ // Any "<access|oauth|bearer> token could not be refreshed" — a refresh
243
+ // failure is always an auth problem, so don't pin it to "oauth".
244
+ /token could not be refreshed/i,
245
+ /authentication_error/i,
246
+ /invalid (api key|bearer token|access token|oauth token)/i,
247
+ /(please )?run `?claude (login|setup-token)`?/i,
248
+ ];
249
+
250
+ /**
251
+ * Best-effort: does an agent error message indicate a revoked/expired Claude
252
+ * credential? The token is injected as an ENV var at runner spawn, so a match
253
+ * routes to refreshAgentToken (re-spawn) — the only way an env token changes
254
+ * for a running task. Heuristic by nature; the DETERMINISTIC recovery is
255
+ * refreshEngineAccountAgents on reconnect, which needs no message match.
256
+ * Exported for tests.
257
+ */
258
+ export function isClaudeAuthRevoked(message: string): boolean {
259
+ return CLAUDE_AUTH_REVOKED_PATTERNS.some((re) => re.test(message));
260
+ }
261
+
234
262
  export class Orchestrator {
235
263
  private readonly channels = new Map<string, Channel>();
236
264
  private readonly channelSpecs = new Map<string, ChannelEnsureInput>();
@@ -1686,6 +1714,13 @@ export class Orchestrator {
1686
1714
  break;
1687
1715
  }
1688
1716
  const isClaude = agent?.kind === "claude";
1717
+ if (isClaude && agent && isClaudeAuthRevoked(event.message)) {
1718
+ // Token revoked/expired: the env-injected credential only changes on
1719
+ // a fresh runner. Re-spawn on the re-resolved account (picks up a
1720
+ // reconnected token); bounded so a still-bad token can't loop.
1721
+ await this.refreshAgentToken(channel, agent);
1722
+ break;
1723
+ }
1689
1724
  const configMissing = CLAUDE_CONFIG_MISSING_PATTERNS.some((re) =>
1690
1725
  re.test(event.message),
1691
1726
  );
@@ -2010,6 +2045,180 @@ export class Orchestrator {
2010
2045
  return true;
2011
2046
  }
2012
2047
 
2048
+ /**
2049
+ * Tear an agent's session down and spawn a FRESH one bound to `account`,
2050
+ * re-delivering the in-flight prompt. Sibling primitive to rotateAccount:
2051
+ * closing the old session stops its runner, so the replacement is a fresh
2052
+ * spawn — the ONLY way an env-injected credential (Claude's
2053
+ * CLAUDE_CODE_OAUTH_TOKEN, passed via `docker exec -e` at spawn) changes for
2054
+ * a RUNNING task. Returns true when handled (bound, or the channel/task went
2055
+ * away). Kept separate from rotateAccount so the rate-limit path is untouched.
2056
+ */
2057
+ private async respawnAgentOnAccount(
2058
+ channel: Channel,
2059
+ agentId: string,
2060
+ agent: RosterAgent,
2061
+ account: ResolvedEngineAccount,
2062
+ note: string,
2063
+ ): Promise<boolean> {
2064
+ const task = getHostTask(channel.taskId);
2065
+ if (!task || task.statusMirror !== "running") return false;
2066
+
2067
+ let replacement: AgentSession | null = null;
2068
+ channel.spawning.add(agentId);
2069
+ try {
2070
+ // Delete first so the old session's final adapter event is
2071
+ // generation-stale, while `spawning` blocks a concurrent replacement.
2072
+ const old = channel.sessions.get(agentId);
2073
+ channel.sessions.delete(agentId);
2074
+ channel.browserStaleSessions.delete(agentId);
2075
+ channel.browserPendingStaleSessions.delete(agentId);
2076
+ if (old) {
2077
+ try {
2078
+ await old.close();
2079
+ } catch {
2080
+ /* already gone — close is idempotent for our adapters */
2081
+ }
2082
+ }
2083
+ if (!this.isActiveChannel(channel)) return true;
2084
+
2085
+ await this.ensureBrowserSetup(
2086
+ channel,
2087
+ channel.roster.some((candidate) => candidate.kind === "codex"),
2088
+ true,
2089
+ );
2090
+ if (!this.isActiveChannel(channel)) return true;
2091
+ await this.ensureMcpConfig(channel);
2092
+ if (!this.isActiveChannel(channel)) return true;
2093
+
2094
+ const apiUrl = apiUrlFromCloudUrl(env.UAI_CLOUD_URL);
2095
+ const cliSecret = loadTaskCliSecret(channel.taskId);
2096
+ const base = agentCliEnv(
2097
+ channel.taskId,
2098
+ agent,
2099
+ task.ownerUserId,
2100
+ apiUrl,
2101
+ cliSecret,
2102
+ );
2103
+ channel.accountByAgent.set(agentId, account.id);
2104
+ noteEngineAccountUsed(account.id);
2105
+
2106
+ replacement = await this.createAndBindStableSession(channel, agentId, {
2107
+ taskId: channel.taskId,
2108
+ agent,
2109
+ containerName: channel.containerName,
2110
+ systemPreamble: channel.preambles.get(agentId) ?? "",
2111
+ agentEnv: { ...base, ...account.execEnv },
2112
+ });
2113
+ if (!replacement) return true;
2114
+
2115
+ this.emitSystemNote(channel.taskId, note);
2116
+
2117
+ // Re-deliver the in-flight prompt so the interrupted turn resumes.
2118
+ const prompt = channel.lastPrompt.get(agentId);
2119
+ if (prompt) {
2120
+ this.incrementActiveTurns(channel, agentId);
2121
+ void replacement.send(prompt).catch((err: unknown) => {
2122
+ if (channel.sessions.get(agentId) === replacement) {
2123
+ this.decrementActiveTurns(channel, agentId);
2124
+ }
2125
+ console.warn(
2126
+ `[orchestrator] ${channel.taskId}/${agentId}: respawn send failed: ${
2127
+ err instanceof Error ? err.message : String(err)
2128
+ }`,
2129
+ );
2130
+ });
2131
+ }
2132
+ } finally {
2133
+ channel.spawning.delete(agentId);
2134
+ }
2135
+
2136
+ if (
2137
+ replacement &&
2138
+ this.isActiveChannel(channel) &&
2139
+ channel.browserStaleSessions.size > 0
2140
+ ) {
2141
+ void this.reconcileSessions(channel).catch((err: unknown) => {
2142
+ console.warn(
2143
+ `[orchestrator] ${channel.taskId}: post-respawn reconcile failed: ${
2144
+ err instanceof Error ? err.message : String(err)
2145
+ }`,
2146
+ );
2147
+ });
2148
+ }
2149
+ return true;
2150
+ }
2151
+
2152
+ /**
2153
+ * Refresh ONE agent's credential by re-spawning it on its current
2154
+ * (re-resolved) account — env accounts read process.env at spawn, so a
2155
+ * reconnected token lands. Bounded by the respawn budget so a still-bad
2156
+ * credential can't loop; on exhaustion, tell the user to reconnect.
2157
+ */
2158
+ private async refreshAgentToken(
2159
+ channel: Channel,
2160
+ agent: RosterAgent,
2161
+ ): Promise<void> {
2162
+ const agentId = agent.id;
2163
+ if (!this.isActiveChannel(channel) || channel.spawning.has(agentId)) return;
2164
+
2165
+ const tries = (channel.respawns.get(agentId) ?? 0) + 1;
2166
+ channel.respawns.set(agentId, tries);
2167
+ channel.respawnLastAt.set(agentId, Date.now());
2168
+ if (tries > MAX_RESPAWNS_PER_AGENT) {
2169
+ this.emitHost({
2170
+ kind: "agent.exit",
2171
+ taskId: channel.taskId,
2172
+ agentId,
2173
+ reason:
2174
+ `${agent.kind} auth keeps failing after ${tries - 1} restarts — ` +
2175
+ `reconnect ${agent.kind} on Account, then message the agent to recover.`,
2176
+ });
2177
+ channel.sessions.delete(agentId);
2178
+ return;
2179
+ }
2180
+
2181
+ const accounts = resolveEngineAccounts(agent.kind);
2182
+ const account =
2183
+ accounts.find((a) => a.id === channel.accountByAgent.get(agentId)) ??
2184
+ accounts[0];
2185
+ if (!account) return;
2186
+
2187
+ await this.respawnAgentOnAccount(
2188
+ channel,
2189
+ agentId,
2190
+ agent,
2191
+ account,
2192
+ `${agentId}: ${agent.kind} credential refreshed — restarted the agent.`,
2193
+ );
2194
+ }
2195
+
2196
+ /**
2197
+ * Re-spawn every running agent of `kind` on its current account with a fresh
2198
+ * runner — picking up a just-reconnected credential (env accounts re-read
2199
+ * process.env at spawn). Call this when the host's credential for `kind`
2200
+ * changes (a re-login). Resets each agent's respawn budget first so an agent
2201
+ * that exhausted it during the outage gets a clean chance on the new token.
2202
+ */
2203
+ async refreshEngineAccountAgents(kind: string): Promise<void> {
2204
+ for (const channel of this.channels.values()) {
2205
+ if (!this.isActiveChannel(channel)) continue;
2206
+ for (const agent of channel.roster) {
2207
+ if (agent.kind !== kind || !channel.sessions.has(agent.id)) continue;
2208
+ channel.respawns.set(agent.id, 0);
2209
+ try {
2210
+ await this.refreshAgentToken(channel, agent);
2211
+ } catch (err) {
2212
+ console.warn(
2213
+ `[orchestrator] ${channel.taskId}/${agent.id}: token refresh failed: ${
2214
+ err instanceof Error ? err.message : String(err)
2215
+ }`,
2216
+ );
2217
+ }
2218
+ }
2219
+ }
2220
+ }
2221
+
2013
2222
  // -- permission resolution ------------------------------------------------
2014
2223
 
2015
2224
  async resolvePermission(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runuai/host",
3
- "version": "0.9.4",
3
+ "version": "0.9.5",
4
4
  "description": "Uai host — runs ephemeral AI coding tasks in Docker on a machine you control.",
5
5
  "license": "MIT",
6
6
  "author": "Diogo Perillo <diogo.perillo@gmail.com>",
package/src/ui/server.ts CHANGED
@@ -25,6 +25,7 @@ import type { ZodType } from "zod";
25
25
 
26
26
  import { agentMode } from "../../lib/agents/mode";
27
27
  import { schema, type Db } from "../../lib/db";
28
+ import { getOrchestrator } from "../../lib/orchestrator";
28
29
  import { parsePreviewPortRuntimes } from "../../lib/preview-ports";
29
30
  import { getCloudState } from "../../lib/cloud-state";
30
31
  import { dockerCli } from "../../lib/docker-exec";
@@ -311,6 +312,13 @@ async function handleEngineConnect(
311
312
  // an optional engine's CLI (kimi/grok/cursor) is installed. Best-effort.
312
313
  opts.readvertise?.();
313
314
  void ensureStandardImage();
315
+ // A re-login only reaches a RUNNING task once its agents re-spawn — the
316
+ // credential is injected at spawn (Claude's token via `docker exec -e`).
317
+ // Refresh running agents of this kind so a reconnect self-heals tasks stuck
318
+ // on a revoked/expired token, instead of needing a manual Stop/Resume.
319
+ void getOrchestrator()
320
+ .refreshEngineAccountAgents(kind)
321
+ .catch(() => {});
314
322
  }
315
323
  emit({ done: true, ok: result.ok, message: result.message });
316
324
  res.end();