@runuai/host 0.9.4 → 0.9.6
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/lib/orchestrator.ts +244 -0
- package/package.json +1 -1
- package/scripts/agent/task-up.sh +16 -2
- package/src/ui/server.ts +8 -0
package/lib/orchestrator.ts
CHANGED
|
@@ -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(
|
|
@@ -2963,6 +3172,38 @@ async function dockerStart(containerName: string): Promise<boolean> {
|
|
|
2963
3172
|
return true;
|
|
2964
3173
|
}
|
|
2965
3174
|
|
|
3175
|
+
/**
|
|
3176
|
+
* Repair the shared node data root before any recovered-container init.
|
|
3177
|
+
* Older task-up versions created the OpenCode leaf as root and accidentally
|
|
3178
|
+
* left this parent root-owned, which prevented code-server from creating its
|
|
3179
|
+
* managed User profile. `install -d` creates or repairs the exact directory
|
|
3180
|
+
* without recursively changing unrelated application state beneath it.
|
|
3181
|
+
*/
|
|
3182
|
+
async function repairNodeDataRoot(containerName: string): Promise<boolean> {
|
|
3183
|
+
const res = await dockerCli([
|
|
3184
|
+
"exec",
|
|
3185
|
+
"-u",
|
|
3186
|
+
"root",
|
|
3187
|
+
containerName,
|
|
3188
|
+
"/usr/bin/install",
|
|
3189
|
+
"-d",
|
|
3190
|
+
"-o",
|
|
3191
|
+
"node",
|
|
3192
|
+
"-g",
|
|
3193
|
+
"node",
|
|
3194
|
+
"-m",
|
|
3195
|
+
"0755",
|
|
3196
|
+
"/home/node/.local/share",
|
|
3197
|
+
]);
|
|
3198
|
+
if (res.status !== 0) {
|
|
3199
|
+
console.error(
|
|
3200
|
+
`[orchestrator] recovery: ${containerName} could not repair /home/node/.local/share ownership: ${res.stderr.trim() || `docker exec exited ${String(res.status)}`}`,
|
|
3201
|
+
);
|
|
3202
|
+
return false;
|
|
3203
|
+
}
|
|
3204
|
+
return true;
|
|
3205
|
+
}
|
|
3206
|
+
|
|
2966
3207
|
async function dockerPort(
|
|
2967
3208
|
containerName: string,
|
|
2968
3209
|
containerPort: number,
|
|
@@ -3163,6 +3404,9 @@ async function recoverOneTask(
|
|
|
3163
3404
|
});
|
|
3164
3405
|
return true;
|
|
3165
3406
|
}
|
|
3407
|
+
// uai-init seeds code-server's profile beneath this shared data root. Heal
|
|
3408
|
+
// containers created by older hosts before running it as node.
|
|
3409
|
+
await repairNodeDataRoot(containerName);
|
|
3166
3410
|
// Correctly-owned Codex creds BEFORE uai-init / any agent respawn: the boot
|
|
3167
3411
|
// reinject sweep only targets containers already running, so a container
|
|
3168
3412
|
// recovered here would otherwise keep whatever it held when it exited —
|
package/package.json
CHANGED
package/scripts/agent/task-up.sh
CHANGED
|
@@ -1244,6 +1244,21 @@ managed_clean_env=(
|
|
|
1244
1244
|
"PATH=/usr/bin:/bin"
|
|
1245
1245
|
)
|
|
1246
1246
|
|
|
1247
|
+
# OpenCode and code-server share ~/.local/share. OpenCode credentials are
|
|
1248
|
+
# copied as root below, so creating only its leaf with `mkdir -p` can leave the
|
|
1249
|
+
# shared parent root-owned (live 2026-08-04). Then node cannot create
|
|
1250
|
+
# code-server's User profile and the editor silently falls back to its light,
|
|
1251
|
+
# full-chrome defaults. Repair both explicitly on every start/resume; GNU
|
|
1252
|
+
# `install -d` also corrects an existing directory with the wrong owner.
|
|
1253
|
+
if ! docker exec -u root "${managed_exec_env[@]}" "$app_container" \
|
|
1254
|
+
/usr/bin/install -d -o node -g node -m 0755 \
|
|
1255
|
+
/home/node/.local/share \
|
|
1256
|
+
/home/node/.local/share/opencode >/dev/null; then
|
|
1257
|
+
emit_err "CONTAINER_INIT_FAILED" \
|
|
1258
|
+
"Uai could not prepare the task's node-owned application data directory. Retry the task; the editor cannot load its settings until this succeeds." \
|
|
1259
|
+
"prepare container application data"
|
|
1260
|
+
fi
|
|
1261
|
+
|
|
1247
1262
|
# Git rejects a repository owned by another uid even when its shared group is
|
|
1248
1263
|
# writable. Trust only the selected worktrees in this task container—never `*`
|
|
1249
1264
|
# and never the host cache. The task-private ~/.gitconfig makes these entries
|
|
@@ -1404,8 +1419,7 @@ docker exec -u root "$app_container" \
|
|
|
1404
1419
|
# binary is baked into the image; here we copy the arch-independent auth+config.
|
|
1405
1420
|
# ADR-076 EXTRA accounts (isolated dirs) are copied later from the host-agent
|
|
1406
1421
|
# (provisionEngineAccounts) since they live in the sealed DB, not on disk here.
|
|
1407
|
-
|
|
1408
|
-
mkdir -p /home/node/.local/share/opencode >/dev/null 2>&1 || true
|
|
1422
|
+
# The shared parent and this leaf were created with node ownership above.
|
|
1409
1423
|
for oc_item in auth.json config.json; do
|
|
1410
1424
|
if [ -e "$UAI_OWNER_HOME/.local/share/opencode/$oc_item" ]; then
|
|
1411
1425
|
docker cp "$UAI_OWNER_HOME/.local/share/opencode/$oc_item" \
|
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();
|