@runuai/host 0.8.23 → 0.8.25
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/agent.ts +27 -1
- package/lib/git-identity.ts +72 -24
- package/package.json +1 -1
package/lib/agent.ts
CHANGED
|
@@ -178,9 +178,13 @@ async function runAgent<T extends z.ZodTypeAny>(
|
|
|
178
178
|
if (parsed) {
|
|
179
179
|
const failure = FailureEnvelope.safeParse(parsed);
|
|
180
180
|
if (failure.success) {
|
|
181
|
+
// Prefer the actual tool output over the bash envelope's generic
|
|
182
|
+
// "agent step failed" — the docker/git/apt error is what the operator
|
|
183
|
+
// needs, and it must reach the cloud, not just the host log.
|
|
184
|
+
const detail = toolStderrDetail(stderrBuf);
|
|
181
185
|
throw new AgentError(
|
|
182
186
|
failure.data.error.code,
|
|
183
|
-
failure.data.error.message,
|
|
187
|
+
detail || failure.data.error.message,
|
|
184
188
|
{
|
|
185
189
|
step: failure.data.error.step,
|
|
186
190
|
exitCode: failure.data.error.exit_code ?? exitCode,
|
|
@@ -236,6 +240,28 @@ function tryParseLastJsonLine(buf: string): unknown {
|
|
|
236
240
|
return null;
|
|
237
241
|
}
|
|
238
242
|
|
|
243
|
+
/**
|
|
244
|
+
* The FAILING TOOL's output from a script's stderr — the docker/git/apt lines
|
|
245
|
+
* that actually say WHY a step failed, dropping our own `[uai-agent] step:`
|
|
246
|
+
* breadcrumbs and the `{"ok":false,…}` envelope. The bash envelope's message
|
|
247
|
+
* is generic ("agent step failed"); this is what makes it specific so the
|
|
248
|
+
* reason reaches the cloud UI instead of only the host log — useless when the
|
|
249
|
+
* host is a remote user's machine (live 2026-07-22, "compose_up_failed" with
|
|
250
|
+
* no why). Bounded to the last few lines so a build log doesn't flood the UI.
|
|
251
|
+
*/
|
|
252
|
+
export function toolStderrDetail(stderr: string): string {
|
|
253
|
+
const lines = stderr
|
|
254
|
+
.split(/\r?\n/)
|
|
255
|
+
.map((l) => l.replace(/\s+$/, ""))
|
|
256
|
+
.filter((l) => l.trim().length > 0)
|
|
257
|
+
.filter((l) => !l.startsWith("[uai-agent"))
|
|
258
|
+
.filter((l) => !l.trimStart().startsWith('{"ok"'));
|
|
259
|
+
return lines
|
|
260
|
+
.slice(-6)
|
|
261
|
+
.map((l) => (l.length > 300 ? `${l.slice(0, 300)}…` : l))
|
|
262
|
+
.join("\n");
|
|
263
|
+
}
|
|
264
|
+
|
|
239
265
|
// ---------------------------------------------------------------------------
|
|
240
266
|
// Public API. One function per agent command.
|
|
241
267
|
// ---------------------------------------------------------------------------
|
package/lib/git-identity.ts
CHANGED
|
@@ -123,38 +123,86 @@ export async function injectSshIdentity(
|
|
|
123
123
|
return true;
|
|
124
124
|
}
|
|
125
125
|
|
|
126
|
+
// One in-flight ensure per task. Channel-ensure fires on every reconnect/
|
|
127
|
+
// nudge (seconds apart under load), and `docker cp` is unlink+create — two
|
|
128
|
+
// concurrent injects interleave so one's `cp id_ed25519` lands AFTER the
|
|
129
|
+
// other's chown, leaving the private key owned by the macOS uid and
|
|
130
|
+
// UNREADABLE by node inside the container ("Load key: Permission denied",
|
|
131
|
+
// live 2026-07-21). Serialize, and skip entirely when the key is already
|
|
132
|
+
// good, so the steady state does zero docker work.
|
|
133
|
+
const sshEnsureInFlight = new Set<string>();
|
|
134
|
+
|
|
135
|
+
/** True when node can already read a correctly-owned key in the container. */
|
|
136
|
+
async function sshKeyHealthy(
|
|
137
|
+
taskId: string,
|
|
138
|
+
exec: DockerExec,
|
|
139
|
+
): Promise<boolean> {
|
|
140
|
+
const res = await exec([
|
|
141
|
+
"exec",
|
|
142
|
+
"-u",
|
|
143
|
+
"node",
|
|
144
|
+
`task-${taskId}-app-1`,
|
|
145
|
+
"test",
|
|
146
|
+
"-r",
|
|
147
|
+
"/home/node/.ssh/id_ed25519",
|
|
148
|
+
]);
|
|
149
|
+
return res.status === 0;
|
|
150
|
+
}
|
|
151
|
+
|
|
126
152
|
/**
|
|
127
|
-
*
|
|
128
|
-
*
|
|
129
|
-
*
|
|
130
|
-
*
|
|
153
|
+
* Ensure the task container has a node-readable SSH push identity + git
|
|
154
|
+
* config. Idempotent and race-free: skips the copy when the key is already
|
|
155
|
+
* healthy (the common case), and never runs two injects for one task at
|
|
156
|
+
* once. Only (re)materializes + copies the key when it is missing or
|
|
157
|
+
* unreadable. Safe to call on every channel ensure.
|
|
131
158
|
*/
|
|
132
159
|
export async function ensureTaskSshIdentity(
|
|
133
160
|
taskId: string,
|
|
134
161
|
ownerUserId: string | null | undefined,
|
|
135
162
|
exec: DockerExec = defaultExec,
|
|
136
163
|
): Promise<boolean> {
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
const dir = writeTaskIdentity(taskId, ownerUserId);
|
|
140
|
-
if (dir) {
|
|
141
|
-
keyPath = resolve(dir, "id_ed25519");
|
|
142
|
-
perTask = true;
|
|
143
|
-
} else {
|
|
144
|
-
const operatorKey = resolve(env.dataDir, "identity", "id_ed25519");
|
|
145
|
-
if (existsSync(operatorKey)) keyPath = operatorKey;
|
|
146
|
-
}
|
|
147
|
-
if (!keyPath) {
|
|
148
|
-
console.log(
|
|
149
|
-
`[ssh] task ${taskId}: no SSH identity for owner or operator — git pushes stay on HTTPS`,
|
|
150
|
-
);
|
|
151
|
-
return false;
|
|
152
|
-
}
|
|
164
|
+
if (sshEnsureInFlight.has(taskId)) return true; // another ensure owns it
|
|
165
|
+
sshEnsureInFlight.add(taskId);
|
|
153
166
|
try {
|
|
154
|
-
|
|
167
|
+
// Fast path: key already good → re-assert only the (cheap, idempotent)
|
|
168
|
+
// git config and skip the docker cp churn that causes the ownership race.
|
|
169
|
+
if (await sshKeyHealthy(taskId, exec)) {
|
|
170
|
+
await exec([
|
|
171
|
+
"exec",
|
|
172
|
+
"-u",
|
|
173
|
+
"node",
|
|
174
|
+
`task-${taskId}-app-1`,
|
|
175
|
+
"sh",
|
|
176
|
+
"-c",
|
|
177
|
+
SSH_GIT_CONFIG,
|
|
178
|
+
]);
|
|
179
|
+
return true;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
let keyPath: string | null = null;
|
|
183
|
+
let perTask = false;
|
|
184
|
+
const dir = writeTaskIdentity(taskId, ownerUserId);
|
|
185
|
+
if (dir) {
|
|
186
|
+
keyPath = resolve(dir, "id_ed25519");
|
|
187
|
+
perTask = true;
|
|
188
|
+
} else {
|
|
189
|
+
const operatorKey = resolve(env.dataDir, "identity", "id_ed25519");
|
|
190
|
+
if (existsSync(operatorKey)) keyPath = operatorKey;
|
|
191
|
+
}
|
|
192
|
+
if (!keyPath) {
|
|
193
|
+
console.log(
|
|
194
|
+
`[ssh] task ${taskId}: no SSH identity for owner or operator — git pushes stay on HTTPS`,
|
|
195
|
+
);
|
|
196
|
+
return false;
|
|
197
|
+
}
|
|
198
|
+
try {
|
|
199
|
+
return await injectSshIdentity(taskId, keyPath, exec);
|
|
200
|
+
} finally {
|
|
201
|
+
// Host hygiene (same as task-up): the materialized private key never
|
|
202
|
+
// outlives the injection.
|
|
203
|
+
if (perTask) removeTaskIdentity(taskId);
|
|
204
|
+
}
|
|
155
205
|
} finally {
|
|
156
|
-
|
|
157
|
-
// outlives the injection.
|
|
158
|
-
if (perTask) removeTaskIdentity(taskId);
|
|
206
|
+
sshEnsureInFlight.delete(taskId);
|
|
159
207
|
}
|
|
160
208
|
}
|
package/package.json
CHANGED