@runuai/host 0.9.13 → 0.9.42
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/README.md +22 -5
- package/db/migrations/0014_host_inventory_event_index.sql +1 -0
- package/db/migrations/0015_host_settings.sql +9 -0
- package/db/migrations/0016_task_environment.sql +2 -0
- package/db/migrations/meta/_journal.json +21 -0
- package/db/schema.ts +80 -30
- package/images/standard/Dockerfile +36 -10
- package/images/standard/README.md +63 -18
- package/images/standard/container/corepack-version +1 -0
- package/images/standard/container/uai-init +308 -38
- package/images/standard/container/uai-materialize-runtimes +1527 -0
- package/lib/agent-cli.ts +69 -4
- package/lib/agent.ts +46 -7
- package/lib/agents/claude.ts +13 -8
- package/lib/agents/codex.ts +11 -6
- package/lib/agents/cursor.ts +39 -29
- package/lib/agents/durable-proc.ts +20 -27
- package/lib/agents/factory.ts +9 -25
- package/lib/agents/grok.ts +43 -30
- package/lib/agents/kimi.ts +44 -29
- package/lib/agents/opencode.ts +43 -31
- package/lib/agents/proc.ts +149 -114
- package/lib/agents/transport.ts +62 -50
- package/lib/agents/types.ts +6 -4
- package/lib/apple-runtime-recycle.ts +236 -0
- package/lib/apple-uninstall-teardown.ts +224 -0
- package/lib/browser-testing.ts +233 -93
- package/lib/codex-auth.ts +40 -6
- package/lib/command-db.ts +20 -0
- package/lib/container-runtime.ts +1338 -0
- package/lib/db.ts +1 -0
- package/lib/docker-exec.ts +87 -5
- package/lib/engine-accounts.ts +68 -5
- package/lib/engine-login.ts +1952 -0
- package/lib/enrollment-state.ts +251 -0
- package/lib/env-file.ts +155 -0
- package/lib/env.ts +4 -0
- package/lib/git-diff.ts +98 -32
- package/lib/git-identity.ts +199 -87
- package/lib/github-tokens.ts +202 -91
- package/lib/host-cloud-url.ts +62 -0
- package/lib/host-config.ts +279 -0
- package/lib/host-logs.ts +962 -0
- package/lib/keyed-promise-tail.ts +23 -0
- package/lib/legacy-runtime-v1.fixture.ts +627 -0
- package/lib/managed-activation-watcher.ts +72 -0
- package/lib/managed-install-owner-watcher.ts +55 -0
- package/lib/managed-operation-drain.ts +49 -0
- package/lib/managed-runtime.ts +3644 -0
- package/lib/managed-update-scheduler.ts +125 -0
- package/lib/mcp-gateway.ts +450 -23
- package/lib/orchestrator.ts +3070 -223
- package/lib/preview-sidecar.ts +57 -13
- package/lib/release-manifest.ts +708 -0
- package/lib/release-trust.ts +28 -0
- package/lib/runtime-activation-tail.ts +232 -0
- package/lib/runtime-archive.ts +1086 -0
- package/lib/runtime-authority.ts +79 -0
- package/lib/runtime-guard.ts +36 -0
- package/lib/runtime-provider-state.ts +169 -0
- package/lib/runtime-state.ts +232 -12
- package/lib/skills.ts +24 -3
- package/lib/ssh.ts +18 -0
- package/lib/standard-image.ts +1104 -141
- package/lib/stopped-task-status-queue.ts +44 -0
- package/lib/task-container-cli.ts +269 -0
- package/lib/task-diff.ts +66 -46
- package/lib/task-environment/apple-container.ts +757 -0
- package/lib/task-environment/docker.ts +945 -0
- package/lib/task-environment/index.ts +364 -0
- package/lib/task-environment/legacy-adoption.ts +443 -0
- package/lib/task-environment/registry.ts +58 -0
- package/lib/task-environment/types.ts +408 -0
- package/lib/task-identity.ts +19 -0
- package/lib/task-inventory.ts +585 -0
- package/lib/tunnel-registry.ts +135 -19
- package/lib/tunnel-runtime.ts +235 -0
- package/package.json +1 -1
- package/scripts/agent/_common.sh +123 -3
- package/scripts/agent/task-down.sh +146 -38
- package/scripts/agent/task-status.sh +19 -3
- package/scripts/agent/task-up.sh +1463 -109
- package/scripts/install/darwin.ts +848 -50
- package/scripts/install/linux.ts +838 -35
- package/scripts/install/types.ts +43 -0
- package/scripts/install/util.ts +215 -8
- package/scripts/install/win.ts +12 -0
- package/src/apple-tunnel-route.ts +104 -0
- package/src/cli.ts +1464 -72
- package/src/event-outbox.ts +83 -4
- package/src/index.ts +871 -50
- package/src/main.ts +1398 -255
- package/src/paths.ts +17 -1
- package/src/protocol.ts +695 -1
- package/src/runtime-bootstrap.ts +165 -0
- package/src/ui/server.ts +46 -10
- package/src/ui/types.ts +37 -0
package/lib/agent-cli.ts
CHANGED
|
@@ -11,16 +11,31 @@
|
|
|
11
11
|
* verifies it. Agents invoke the CLI as `node /workspace/.uai/cli.mjs <cmd>`
|
|
12
12
|
* (surfaced in the system preamble).
|
|
13
13
|
*/
|
|
14
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
chmodSync,
|
|
16
|
+
lstatSync,
|
|
17
|
+
mkdirSync,
|
|
18
|
+
readFileSync,
|
|
19
|
+
rmSync,
|
|
20
|
+
writeFileSync,
|
|
21
|
+
} from "node:fs";
|
|
15
22
|
import { resolve } from "node:path";
|
|
16
23
|
|
|
17
24
|
import { env, taskWorkspaceDir } from "./env";
|
|
25
|
+
import { RUNTIME_AUTHORITY_ENV } from "./runtime-authority";
|
|
18
26
|
import { signTaskToken } from "./task-token";
|
|
27
|
+
import { assertSafeHostTaskId } from "./task-identity";
|
|
19
28
|
import type { RosterAgent } from "./agents/types";
|
|
20
29
|
|
|
21
30
|
/** Container path of the CLI — what the preamble tells agents to run. */
|
|
22
31
|
export const CONTAINER_CLI_PATH = "/workspace/.uai/cli.mjs";
|
|
23
32
|
|
|
33
|
+
/** Runtime/cache authority for every agent docker exec, including preserved
|
|
34
|
+
* pre-L4 containers whose Config.Env may still contain project-controlled
|
|
35
|
+
* PATH/asdf/Corepack/loader values. Callers merge engine-account credentials
|
|
36
|
+
* underneath this object so those credentials cannot replace host policy. */
|
|
37
|
+
export const AGENT_RUNTIME_AUTHORITY_ENV = RUNTIME_AUTHORITY_ENV;
|
|
38
|
+
|
|
24
39
|
// The task's cli secret lives HOST-PRIVATE (never the container), so it survives
|
|
25
40
|
// host restarts without a DB migration. The cloud re-sends it on each task-up.
|
|
26
41
|
function taskSecretPath(taskId: string): string {
|
|
@@ -46,6 +61,20 @@ export function loadTaskCliSecret(taskId: string): string | null {
|
|
|
46
61
|
}
|
|
47
62
|
}
|
|
48
63
|
|
|
64
|
+
/** Orphan GC is an authority deletion, not best-effort housekeeping. */
|
|
65
|
+
export function removeTaskCliSecretStrict(taskId: string): void {
|
|
66
|
+
assertSafeHostTaskId(taskId);
|
|
67
|
+
const path = taskSecretPath(taskId);
|
|
68
|
+
rmSync(path, { force: true });
|
|
69
|
+
try {
|
|
70
|
+
lstatSync(path);
|
|
71
|
+
} catch (error) {
|
|
72
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return;
|
|
73
|
+
throw error;
|
|
74
|
+
}
|
|
75
|
+
throw new Error("task CLI secret remains after removal");
|
|
76
|
+
}
|
|
77
|
+
|
|
49
78
|
/** The union used only to decide whether this task needs the shared CLI file. */
|
|
50
79
|
export function rosterPermissions(roster: RosterAgent[]): string[] {
|
|
51
80
|
return Array.from(new Set(roster.flatMap((a) => a.permissions ?? [])));
|
|
@@ -117,6 +146,7 @@ export function agentCliEnv(
|
|
|
117
146
|
cliSecret: string | null,
|
|
118
147
|
allowPermissionless = false,
|
|
119
148
|
): Record<string, string> {
|
|
149
|
+
const runtimeAuthority = { ...AGENT_RUNTIME_AUTHORITY_ENV };
|
|
120
150
|
const permissions = agent.permissions ?? [];
|
|
121
151
|
if (
|
|
122
152
|
!ownerUserId ||
|
|
@@ -124,9 +154,10 @@ export function agentCliEnv(
|
|
|
124
154
|
!cliSecret ||
|
|
125
155
|
(permissions.length === 0 && !allowPermissionless)
|
|
126
156
|
) {
|
|
127
|
-
return
|
|
157
|
+
return runtimeAuthority;
|
|
128
158
|
}
|
|
129
159
|
return {
|
|
160
|
+
...runtimeAuthority,
|
|
130
161
|
UAI_TASK_TOKEN: signTaskToken(
|
|
131
162
|
{ taskId, userId: ownerUserId, permissions, agentId: agent.id },
|
|
132
163
|
cliSecret,
|
|
@@ -168,6 +199,22 @@ function parseFlags(args) {
|
|
|
168
199
|
return { flags, rest };
|
|
169
200
|
}
|
|
170
201
|
|
|
202
|
+
// A punchlist item's text is prose, and prose written in THIS repo contains
|
|
203
|
+
// --id and --text. parseFlags is global and greedy, so routing todo text
|
|
204
|
+
// through it silently rewrote the request: "todo edit aaaaaa the --id flag"
|
|
205
|
+
// resolved to { ref: "flag", text: "aaaaaa the" } and edited a different item,
|
|
206
|
+
// and a valueless --text became the literal string "true" — an item
|
|
207
|
+
// overwritten with garbage, or worse, the wrong item overwritten at all. Todo
|
|
208
|
+
// text is therefore read from the raw argv tail and never flag-parsed. The
|
|
209
|
+
// --id <ref> form survives only in leading position, where it cannot be
|
|
210
|
+
// mistaken for prose.
|
|
211
|
+
function todoRef(args) {
|
|
212
|
+
return args[0] === "--id"
|
|
213
|
+
? { ref: args[1], tail: args.slice(2) }
|
|
214
|
+
: { ref: args[0] && !args[0].startsWith("--") ? args[0] : "", tail: args.slice(1) };
|
|
215
|
+
}
|
|
216
|
+
function todoText(args) { return args.join(" ").trim(); }
|
|
217
|
+
|
|
171
218
|
async function api(method, path, body) {
|
|
172
219
|
const res = await fetch(API_URL + path, {
|
|
173
220
|
method,
|
|
@@ -292,14 +339,30 @@ async function main() {
|
|
|
292
339
|
break;
|
|
293
340
|
}
|
|
294
341
|
case "todo add": {
|
|
295
|
-
const text =
|
|
342
|
+
const text = todoText(rest);
|
|
296
343
|
if (!text) { console.error("uai: todo add needs text"); process.exit(1); }
|
|
297
344
|
const t = (await api("POST", "/api/agent/todos", { op: "add", text })).todo;
|
|
298
345
|
out("added #" + t.shortId + ": " + t.text);
|
|
299
346
|
break;
|
|
300
347
|
}
|
|
348
|
+
case "todo edit": {
|
|
349
|
+
const { ref, tail } = todoRef(rest);
|
|
350
|
+
const text = todoText(tail);
|
|
351
|
+
if (!ref) { console.error("uai: todo edit needs an item id (see 'uai todo list')"); process.exit(1); }
|
|
352
|
+
if (!text) { console.error("uai: todo edit needs replacement text"); process.exit(1); }
|
|
353
|
+
const t = (await api("POST", "/api/agent/todos", { op: "edit", ref, text })).todo;
|
|
354
|
+
out("edited #" + t.shortId + ": " + t.text);
|
|
355
|
+
break;
|
|
356
|
+
}
|
|
357
|
+
case "todo remove": {
|
|
358
|
+
const { ref } = todoRef(rest);
|
|
359
|
+
if (!ref) { console.error("uai: todo remove needs an item id (see 'uai todo list')"); process.exit(1); }
|
|
360
|
+
await api("POST", "/api/agent/todos", { op: "remove", ref });
|
|
361
|
+
out("removed #" + String(ref).replace(/^#/, ""));
|
|
362
|
+
break;
|
|
363
|
+
}
|
|
301
364
|
case "todo claim": case "todo done": case "todo reopen": case "todo unclaim": {
|
|
302
|
-
const ref =
|
|
365
|
+
const { ref } = todoRef(rest);
|
|
303
366
|
if (!ref) { console.error("uai: todo " + action + " needs an item id (see 'uai todo list')"); process.exit(1); }
|
|
304
367
|
const t = (await api("POST", "/api/agent/todos", { op: action, ref })).todo;
|
|
305
368
|
out(action + " #" + t.shortId + ": " + t.text);
|
|
@@ -359,6 +422,8 @@ async function main() {
|
|
|
359
422
|
" uai memory delete <id>",
|
|
360
423
|
" uai todo list",
|
|
361
424
|
" uai todo add <text>",
|
|
425
|
+
" uai todo edit <#id> <text>",
|
|
426
|
+
" uai todo remove <#id>",
|
|
362
427
|
" uai todo claim|done|reopen|unclaim <#id>",
|
|
363
428
|
" uai preview add <name> <containerPort>",
|
|
364
429
|
" uai project create --name <n> --repo <git-url> [--prompt <p>]",
|
package/lib/agent.ts
CHANGED
|
@@ -16,24 +16,28 @@
|
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
18
|
import { spawn } from "node:child_process";
|
|
19
|
-
import { dirname, resolve } from "node:path";
|
|
19
|
+
import { dirname, join, resolve } from "node:path";
|
|
20
20
|
import { fileURLToPath } from "node:url";
|
|
21
21
|
import { z } from "zod";
|
|
22
22
|
|
|
23
23
|
import {
|
|
24
|
+
createTaskEnvironmentDownCommandDb,
|
|
24
25
|
createTaskDownCommandDb,
|
|
25
26
|
createTaskStatusCommandDb,
|
|
26
27
|
createTaskUpCommandDb,
|
|
27
28
|
removeCommandDb,
|
|
28
29
|
} from "./command-db";
|
|
29
30
|
import { env } from "./env";
|
|
31
|
+
import { containerRuntimeStandardAssetsRoot } from "./container-runtime";
|
|
30
32
|
import { getDecryptedForProject } from "./host-env";
|
|
31
33
|
import { PreviewPortRuntimesSchema } from "./preview-ports";
|
|
32
34
|
import { getHostTask } from "./runtime-state";
|
|
33
35
|
import { removeTaskIdentity, writeTaskIdentity } from "./ssh";
|
|
34
36
|
import type { TaskDownInput, TaskLaunchInput } from "../src/protocol";
|
|
35
37
|
|
|
36
|
-
|
|
38
|
+
export type { TaskLaunchInput } from "../src/protocol";
|
|
39
|
+
|
|
40
|
+
export interface TaskUpCredentials {
|
|
37
41
|
/** Path to the task owner's private, ephemeral Git credential cache. */
|
|
38
42
|
githubCredentialSocket?: string;
|
|
39
43
|
}
|
|
@@ -151,6 +155,9 @@ async function runAgent<T extends z.ZodTypeAny>(
|
|
|
151
155
|
...process.env,
|
|
152
156
|
UAI_DB_PATH: commandDbPath,
|
|
153
157
|
UAI_DATA_DIR: env.dataDir,
|
|
158
|
+
UAI_STANDARD_CONTAINER_ASSETS_DIR:
|
|
159
|
+
containerRuntimeStandardAssetsRoot() ??
|
|
160
|
+
join(packageAgentScriptsDir(), "..", "..", "images", "standard", "container"),
|
|
154
161
|
...extraEnv,
|
|
155
162
|
},
|
|
156
163
|
});
|
|
@@ -191,13 +198,9 @@ async function runAgent<T extends z.ZodTypeAny>(
|
|
|
191
198
|
if (parsed) {
|
|
192
199
|
const failure = FailureEnvelope.safeParse(parsed);
|
|
193
200
|
if (failure.success) {
|
|
194
|
-
// Prefer the actual tool output over the bash envelope's generic
|
|
195
|
-
// "agent step failed" — the docker/git/apt error is what the operator
|
|
196
|
-
// needs, and it must reach the cloud, not just the host log.
|
|
197
|
-
const detail = toolStderrDetail(stderrBuf);
|
|
198
201
|
throw new AgentError(
|
|
199
202
|
failure.data.error.code,
|
|
200
|
-
|
|
203
|
+
composeFailureMessage(failure.data.error.message, stderrBuf),
|
|
201
204
|
{
|
|
202
205
|
step: failure.data.error.step,
|
|
203
206
|
exitCode: failure.data.error.exit_code ?? exitCode,
|
|
@@ -262,6 +265,23 @@ function tryParseLastJsonLine(buf: string): unknown {
|
|
|
262
265
|
* host is a remote user's machine (live 2026-07-22, "compose_up_failed" with
|
|
263
266
|
* no why). Bounded to the last few lines so a build log doesn't flood the UI.
|
|
264
267
|
*/
|
|
268
|
+
/**
|
|
269
|
+
* The operator-facing failure text: the envelope's message LEADS and the
|
|
270
|
+
* tool output follows. The scripts' emit_err messages are curated
|
|
271
|
+
* instructions ("close another running task, then press Resume") —
|
|
272
|
+
* replacing them with the stderr tail buried exactly the sentence the
|
|
273
|
+
* operator needed under VZ/git noise (live 2026-08-17, twice in one day).
|
|
274
|
+
* The tool detail still ships, because a message alone can lack the WHY
|
|
275
|
+
* (the 2026-07-22 lesson the old detail-first ordering encoded).
|
|
276
|
+
*/
|
|
277
|
+
export function composeFailureMessage(
|
|
278
|
+
envelopeMessage: string,
|
|
279
|
+
stderrBuf: string,
|
|
280
|
+
): string {
|
|
281
|
+
const detail = toolStderrDetail(stderrBuf);
|
|
282
|
+
return detail ? `${envelopeMessage}\n${detail}` : envelopeMessage;
|
|
283
|
+
}
|
|
284
|
+
|
|
265
285
|
export function toolStderrDetail(stderr: string): string {
|
|
266
286
|
const lines = stderr
|
|
267
287
|
.split(/\r?\n/)
|
|
@@ -345,6 +365,25 @@ export const agent = {
|
|
|
345
365
|
}
|
|
346
366
|
},
|
|
347
367
|
|
|
368
|
+
/** ADR-101 Phase C: idempotent teardown from a durable environment locator.
|
|
369
|
+
* Cloud command metadata is intentionally unnecessary at this boundary. */
|
|
370
|
+
async taskEnvironmentDown(taskId: string): Promise<TaskDownResult> {
|
|
371
|
+
const commandDbPath = createTaskEnvironmentDownCommandDb(
|
|
372
|
+
taskId,
|
|
373
|
+
getHostTask(taskId),
|
|
374
|
+
);
|
|
375
|
+
try {
|
|
376
|
+
return await runAgent(
|
|
377
|
+
"task-down.sh",
|
|
378
|
+
[taskId],
|
|
379
|
+
TaskDownData,
|
|
380
|
+
commandDbPath,
|
|
381
|
+
);
|
|
382
|
+
} finally {
|
|
383
|
+
removeCommandDb(commandDbPath);
|
|
384
|
+
}
|
|
385
|
+
},
|
|
386
|
+
|
|
348
387
|
async taskStatus(taskId: string): Promise<TaskStatusResult> {
|
|
349
388
|
const commandDbPath = createTaskStatusCommandDb(taskId, getHostTask(taskId));
|
|
350
389
|
try {
|
package/lib/agents/claude.ts
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* ClaudeSession — a real AgentSession backed by the Claude Code CLI in
|
|
3
|
-
* stream-json mode, run inside the task
|
|
3
|
+
* stream-json mode, run inside the task environment (ADR-010).
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* --verbose --no-session-persistence
|
|
5
|
+
* claude --print --input-format stream-json --output-format stream-json \
|
|
6
|
+
* --verbose --no-session-persistence
|
|
8
7
|
*
|
|
9
8
|
* The CLI is a persistent bidirectional process: uai writes one JSON
|
|
10
9
|
* line per user turn to stdin and reads a stream of JSON event lines
|
|
@@ -20,6 +19,8 @@
|
|
|
20
19
|
* those fixups stay in one place.
|
|
21
20
|
*/
|
|
22
21
|
|
|
22
|
+
import { requireContainerRuntimeOperational } from "../runtime-guard";
|
|
23
|
+
import type { TaskEnvironmentAgentSessionSurface } from "../task-environment/types";
|
|
23
24
|
import { newId } from "../ulid";
|
|
24
25
|
import { createAgentTransport, type LineTransport } from "./transport";
|
|
25
26
|
import { isRateLimitMessage } from "./rate-limit";
|
|
@@ -437,7 +438,7 @@ export class ClaudeSession implements AgentSession {
|
|
|
437
438
|
constructor(args: {
|
|
438
439
|
taskId: string;
|
|
439
440
|
agent: RosterAgent;
|
|
440
|
-
|
|
441
|
+
environment: TaskEnvironmentAgentSessionSurface;
|
|
441
442
|
systemPreamble: string;
|
|
442
443
|
executionProfile?: "communicator";
|
|
443
444
|
attachCompatibilityKey?: string;
|
|
@@ -494,7 +495,7 @@ export class ClaudeSession implements AgentSession {
|
|
|
494
495
|
this.proc = createAgentTransport({
|
|
495
496
|
taskId: args.taskId,
|
|
496
497
|
agentId: this.agentId,
|
|
497
|
-
|
|
498
|
+
environment: args.environment,
|
|
498
499
|
cli: "claude",
|
|
499
500
|
cliArgs,
|
|
500
501
|
passEnv: ["CLAUDE_CODE_OAUTH_TOKEN", "ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"],
|
|
@@ -635,6 +636,10 @@ export class ClaudeSession implements AgentSession {
|
|
|
635
636
|
|
|
636
637
|
async send(text: string): Promise<void> {
|
|
637
638
|
if (this.closed) return;
|
|
639
|
+
// The transport can outlive the command that created it. Gate each new
|
|
640
|
+
// turn at its actual runner boundary, while still allowing interrupt and
|
|
641
|
+
// close traffic during runtime recovery.
|
|
642
|
+
requireContainerRuntimeOperational();
|
|
638
643
|
this.proc.writeLine({
|
|
639
644
|
type: "user",
|
|
640
645
|
message: { role: "user", content: text },
|
|
@@ -711,7 +716,7 @@ register({
|
|
|
711
716
|
create: async ({
|
|
712
717
|
taskId,
|
|
713
718
|
agent,
|
|
714
|
-
|
|
719
|
+
environment,
|
|
715
720
|
systemPreamble,
|
|
716
721
|
executionProfile,
|
|
717
722
|
attachCompatibilityKey,
|
|
@@ -720,7 +725,7 @@ register({
|
|
|
720
725
|
new ClaudeSession({
|
|
721
726
|
taskId,
|
|
722
727
|
agent,
|
|
723
|
-
|
|
728
|
+
environment,
|
|
724
729
|
systemPreamble,
|
|
725
730
|
executionProfile,
|
|
726
731
|
attachCompatibilityKey,
|
package/lib/agents/codex.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* CodexSession — a real AgentSession backed by `codex app-server`, run
|
|
3
|
-
* inside the task
|
|
3
|
+
* inside the task environment (ADR-010):
|
|
4
4
|
*
|
|
5
|
-
*
|
|
5
|
+
* codex app-server
|
|
6
6
|
*
|
|
7
7
|
* JSON-RPC 2.0 over stdio, newline-delimited. The protocol below is
|
|
8
8
|
* verified against `codex app-server generate-json-schema` (the
|
|
@@ -35,6 +35,8 @@ import { join } from "node:path";
|
|
|
35
35
|
import { and, eq } from "drizzle-orm";
|
|
36
36
|
|
|
37
37
|
import { getDb, schema } from "../db";
|
|
38
|
+
import { requireContainerRuntimeOperational } from "../runtime-guard";
|
|
39
|
+
import type { TaskEnvironmentAgentSessionSurface } from "../task-environment/types";
|
|
38
40
|
import { newId } from "../ulid";
|
|
39
41
|
import { createAgentTransport, type LineTransport } from "./transport";
|
|
40
42
|
import { isRateLimitMessage } from "./rate-limit";
|
|
@@ -303,7 +305,7 @@ export class CodexSession implements AgentSession {
|
|
|
303
305
|
constructor(args: {
|
|
304
306
|
taskId: string;
|
|
305
307
|
agent: RosterAgent;
|
|
306
|
-
|
|
308
|
+
environment: TaskEnvironmentAgentSessionSurface;
|
|
307
309
|
systemPreamble: string;
|
|
308
310
|
agentEnv?: Record<string, string>;
|
|
309
311
|
}) {
|
|
@@ -335,7 +337,7 @@ export class CodexSession implements AgentSession {
|
|
|
335
337
|
this.proc = createAgentTransport({
|
|
336
338
|
taskId: args.taskId,
|
|
337
339
|
agentId: this.agentId,
|
|
338
|
-
|
|
340
|
+
environment: args.environment,
|
|
339
341
|
cli: "codex",
|
|
340
342
|
cliArgs: codexArgs,
|
|
341
343
|
passEnv: [],
|
|
@@ -630,6 +632,9 @@ export class CodexSession implements AgentSession {
|
|
|
630
632
|
return;
|
|
631
633
|
}
|
|
632
634
|
|
|
635
|
+
// `ready` may have waited across a runtime generation change. Recheck at
|
|
636
|
+
// the turn/start write rather than trusting session construction time.
|
|
637
|
+
requireContainerRuntimeOperational();
|
|
633
638
|
this.fireRequest("turn/start", {
|
|
634
639
|
threadId: this.threadId,
|
|
635
640
|
input: [{ type: "text", text }],
|
|
@@ -689,6 +694,6 @@ register({
|
|
|
689
694
|
existsSync(
|
|
690
695
|
join(process.env.UAI_OWNER_HOME?.trim() || homedir(), ".codex", "auth.json"),
|
|
691
696
|
),
|
|
692
|
-
create: async ({ taskId, agent,
|
|
693
|
-
new CodexSession({ taskId, agent,
|
|
697
|
+
create: async ({ taskId, agent, environment, systemPreamble, agentEnv }) =>
|
|
698
|
+
new CodexSession({ taskId, agent, environment, systemPreamble, agentEnv }),
|
|
694
699
|
});
|
package/lib/agents/cursor.ts
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* CursorSession — a real AgentSession backed by **Cursor Agent** (`cursor-agent`),
|
|
3
|
-
* run inside the task
|
|
3
|
+
* run inside the task environment:
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
* /home/node/.local/bin/cursor-agent -p "<prompt>" \
|
|
5
|
+
* CURSOR_API_KEY=… /home/node/.local/bin/cursor-agent -p "<prompt>" \
|
|
7
6
|
* --output-format stream-json --stream-partial-output --force --trust \
|
|
8
7
|
* -m <model> [--resume <sessionId>]
|
|
9
8
|
*
|
|
@@ -27,10 +26,18 @@
|
|
|
27
26
|
* `--trust` (skip the workspace-trust prompt). Cursor has no system-prompt
|
|
28
27
|
* flag, so the briefing folds into the first turn's prompt.
|
|
29
28
|
*/
|
|
30
|
-
import {
|
|
31
|
-
|
|
29
|
+
import { requireContainerRuntimeOperational } from "../runtime-guard";
|
|
30
|
+
import type {
|
|
31
|
+
TaskEnvironmentAgentSessionSurface,
|
|
32
|
+
TaskEnvironmentSessionRequest,
|
|
33
|
+
} from "../task-environment/types";
|
|
32
34
|
import { newId } from "../ulid";
|
|
33
35
|
import { register } from "./registry";
|
|
36
|
+
import {
|
|
37
|
+
AGENT_SESSION_OUTPUT_BUFFER_BYTES,
|
|
38
|
+
spawnEnvironmentProcess,
|
|
39
|
+
type AgentEnvironmentProcess,
|
|
40
|
+
} from "./proc";
|
|
34
41
|
import { extractResultUsage } from "./usage";
|
|
35
42
|
import type {
|
|
36
43
|
AgentEvent,
|
|
@@ -147,15 +154,15 @@ export function mapCursorLine(line: string): MappedCursorLine {
|
|
|
147
154
|
// Session
|
|
148
155
|
// ---------------------------------------------------------------------------
|
|
149
156
|
|
|
150
|
-
export type Spawner = (
|
|
151
|
-
|
|
152
|
-
|
|
157
|
+
export type Spawner = (
|
|
158
|
+
request: TaskEnvironmentSessionRequest,
|
|
159
|
+
) => AgentEnvironmentProcess;
|
|
153
160
|
|
|
154
161
|
export class CursorSession implements AgentSession {
|
|
155
162
|
readonly agentId: string;
|
|
156
163
|
readonly kind: AgentKind = "cursor";
|
|
157
164
|
|
|
158
|
-
private readonly
|
|
165
|
+
private readonly environment: TaskEnvironmentAgentSessionSurface;
|
|
159
166
|
private readonly model?: string;
|
|
160
167
|
private readonly systemPreamble: string;
|
|
161
168
|
private readonly agentEnv: Record<string, string>;
|
|
@@ -164,7 +171,7 @@ export class CursorSession implements AgentSession {
|
|
|
164
171
|
|
|
165
172
|
private readonly handlers = new Set<AgentEventHandler>();
|
|
166
173
|
private sessionId: string | null = null;
|
|
167
|
-
private current:
|
|
174
|
+
private current: AgentEnvironmentProcess | null = null;
|
|
168
175
|
private queue: Promise<void> = Promise.resolve();
|
|
169
176
|
private sentPreamble = false;
|
|
170
177
|
private closed = false;
|
|
@@ -172,18 +179,19 @@ export class CursorSession implements AgentSession {
|
|
|
172
179
|
constructor(args: {
|
|
173
180
|
taskId: string;
|
|
174
181
|
agent: RosterAgent;
|
|
175
|
-
|
|
182
|
+
environment: TaskEnvironmentAgentSessionSurface;
|
|
176
183
|
systemPreamble: string;
|
|
177
184
|
agentEnv?: Record<string, string>;
|
|
178
185
|
spawner?: Spawner;
|
|
179
186
|
apiKey?: string;
|
|
180
187
|
}) {
|
|
181
188
|
this.agentId = args.agent.id;
|
|
182
|
-
this.
|
|
189
|
+
this.environment = args.environment;
|
|
183
190
|
this.model = args.agent.model;
|
|
184
191
|
this.systemPreamble = args.systemPreamble;
|
|
185
192
|
this.agentEnv = args.agentEnv ?? {};
|
|
186
|
-
this.spawner =
|
|
193
|
+
this.spawner =
|
|
194
|
+
args.spawner ?? ((request) => spawnEnvironmentProcess(this.environment, request));
|
|
187
195
|
this.apiKey = args.apiKey ?? process.env.CURSOR_API_KEY ?? "";
|
|
188
196
|
}
|
|
189
197
|
|
|
@@ -199,17 +207,13 @@ export class CursorSession implements AgentSession {
|
|
|
199
207
|
|
|
200
208
|
async send(text: string): Promise<void> {
|
|
201
209
|
if (this.closed) return;
|
|
202
|
-
|
|
203
|
-
|
|
210
|
+
const turn = this.queue.catch(() => {}).then(() => this.runTurn(text));
|
|
211
|
+
this.queue = turn;
|
|
212
|
+
return turn;
|
|
204
213
|
}
|
|
205
214
|
|
|
206
|
-
private
|
|
207
|
-
const
|
|
208
|
-
for (const [k, v] of Object.entries(this.agentEnv)) {
|
|
209
|
-
args.push("-e", `${k}=${v}`);
|
|
210
|
-
}
|
|
211
|
-
args.push(
|
|
212
|
-
this.containerName,
|
|
215
|
+
private buildRequest(prompt: string): TaskEnvironmentSessionRequest {
|
|
216
|
+
const argv = [
|
|
213
217
|
CURSOR_BIN,
|
|
214
218
|
"-p",
|
|
215
219
|
prompt,
|
|
@@ -219,14 +223,20 @@ export class CursorSession implements AgentSession {
|
|
|
219
223
|
"--force", // container is the sandbox — auto-run tools
|
|
220
224
|
"--trust", // skip the workspace-trust prompt in headless
|
|
221
225
|
"--approve-mcps", // load the gateway MCP servers from ~/.cursor/mcp.json (ADR-057)
|
|
222
|
-
|
|
223
|
-
if (this.model && this.model !== "auto")
|
|
224
|
-
if (this.sessionId)
|
|
225
|
-
return
|
|
226
|
+
];
|
|
227
|
+
if (this.model && this.model !== "auto") argv.push("-m", this.model);
|
|
228
|
+
if (this.sessionId) argv.push("--resume", this.sessionId);
|
|
229
|
+
return {
|
|
230
|
+
argv: argv as [string, ...string[]],
|
|
231
|
+
user: "node",
|
|
232
|
+
env: { ...this.agentEnv, CURSOR_API_KEY: this.apiKey },
|
|
233
|
+
maxOutputBytes: AGENT_SESSION_OUTPUT_BUFFER_BYTES,
|
|
234
|
+
};
|
|
226
235
|
}
|
|
227
236
|
|
|
228
237
|
private async runTurn(text: string): Promise<void> {
|
|
229
238
|
if (this.closed) return;
|
|
239
|
+
requireContainerRuntimeOperational();
|
|
230
240
|
// No system-prompt flag — fold the briefing into the first turn; later
|
|
231
241
|
// turns carry it via the resumed session.
|
|
232
242
|
const prompt =
|
|
@@ -235,7 +245,7 @@ export class CursorSession implements AgentSession {
|
|
|
235
245
|
: text;
|
|
236
246
|
this.sentPreamble = true;
|
|
237
247
|
|
|
238
|
-
const child = this.spawner(this.
|
|
248
|
+
const child = this.spawner(this.buildRequest(prompt));
|
|
239
249
|
this.current = child;
|
|
240
250
|
|
|
241
251
|
let acc = "";
|
|
@@ -352,6 +362,6 @@ register({
|
|
|
352
362
|
// Gated on a Cursor API key in the host env (loaded from .env.local); the
|
|
353
363
|
// adapter injects it into the container. No key → engine not advertised.
|
|
354
364
|
available: () => Boolean(process.env.CURSOR_API_KEY),
|
|
355
|
-
create: async ({ taskId, agent,
|
|
356
|
-
new CursorSession({ taskId, agent,
|
|
365
|
+
create: async ({ taskId, agent, environment, systemPreamble, agentEnv }) =>
|
|
366
|
+
new CursorSession({ taskId, agent, environment, systemPreamble, agentEnv }),
|
|
357
367
|
});
|
|
@@ -18,7 +18,6 @@
|
|
|
18
18
|
* LineProcess gave them.
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
|
-
import { spawn } from "node:child_process";
|
|
22
21
|
import {
|
|
23
22
|
closeSync,
|
|
24
23
|
fstatSync,
|
|
@@ -47,12 +46,11 @@ export function runnerScriptPath(): string {
|
|
|
47
46
|
export interface DurableProcessOptions {
|
|
48
47
|
/** Session dir as seen FROM THE HOST (on the workspace bind mount). */
|
|
49
48
|
hostSessionDir: string;
|
|
50
|
-
/**
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
spawnCommand?: { command: string; args: string[] };
|
|
49
|
+
/** Provider-owned detached launch. Omit to attach to an existing runner. */
|
|
50
|
+
launch?: () => Promise<{
|
|
51
|
+
exitCode: number | null;
|
|
52
|
+
stderr: Uint8Array;
|
|
53
|
+
}>;
|
|
56
54
|
/** Resume consumption from this outbox byte offset (attach path). */
|
|
57
55
|
initialOutboxOffset?: number;
|
|
58
56
|
/** Called after each poll batch whose lines were delivered — persist this
|
|
@@ -109,26 +107,21 @@ export class DurableProcess {
|
|
|
109
107
|
|
|
110
108
|
mkdirSync(this.dir, { recursive: true });
|
|
111
109
|
|
|
112
|
-
if (opts.
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
child.on("exit", (code) => {
|
|
128
|
-
if (code !== null && code !== 0) this.finish(null);
|
|
129
|
-
});
|
|
130
|
-
child.on("error", () => this.finish(null));
|
|
131
|
-
child.unref();
|
|
110
|
+
if (opts.launch) {
|
|
111
|
+
void opts.launch().then(
|
|
112
|
+
(result) => {
|
|
113
|
+
const stderr = Buffer.from(result.stderr).toString("utf8");
|
|
114
|
+
if (stderr) this.stderrBuf = (this.stderrBuf + stderr).slice(-8192);
|
|
115
|
+
if (result.exitCode !== 0) this.finish(null);
|
|
116
|
+
},
|
|
117
|
+
(error: unknown) => {
|
|
118
|
+
this.stderrBuf = (
|
|
119
|
+
this.stderrBuf +
|
|
120
|
+
(error instanceof Error ? error.message : String(error))
|
|
121
|
+
).slice(-8192);
|
|
122
|
+
this.finish(null);
|
|
123
|
+
},
|
|
124
|
+
);
|
|
132
125
|
} else {
|
|
133
126
|
this.sawSpawnMeta = true; // attach: the runner pre-exists
|
|
134
127
|
}
|
package/lib/agents/factory.ts
CHANGED
|
@@ -8,10 +8,10 @@
|
|
|
8
8
|
* change here. Unknown kinds fail loudly so a typo in a roster surfaces as an
|
|
9
9
|
* error rather than silently falling back to the wrong CLI.
|
|
10
10
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* real is changing which factory the orchestrator is constructed with
|
|
14
|
-
* `getOrchestrator`.
|
|
11
|
+
* Adapters run their CLI through the task environment's provider-owned
|
|
12
|
+
* session surface; the agent's `model` (if set) is passed through. Swapping
|
|
13
|
+
* mock ⇄ real is changing which factory the orchestrator is constructed with
|
|
14
|
+
* — see `getOrchestrator`.
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
// Side-effect imports: register the built-in adapters with the registry.
|
|
@@ -26,26 +26,6 @@ import { agentClisReady } from "../standard-image";
|
|
|
26
26
|
import { factoryFor, supportsExecutionProfile } from "./registry";
|
|
27
27
|
import type { AgentSession, AgentSessionFactory } from "./types";
|
|
28
28
|
|
|
29
|
-
/** Cap the wait on agentClisReady so a spawn can never hang forever if the
|
|
30
|
-
* boot reconcile never signals (e.g. an unexpected code path). Generous —
|
|
31
|
-
* the reconcile normally settles in seconds; this only guards a stuck boot. */
|
|
32
|
-
const CLIS_READY_TIMEOUT_MS = 90_000;
|
|
33
|
-
|
|
34
|
-
/** Await the shared-volume CLI reconcile, bounded by a self-clearing timeout so
|
|
35
|
-
* neither a stuck boot nor a per-spawn timer leak can bite. */
|
|
36
|
-
async function awaitAgentClisReady(): Promise<void> {
|
|
37
|
-
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
38
|
-
const bound = new Promise<void>((resolve) => {
|
|
39
|
-
timer = setTimeout(resolve, CLIS_READY_TIMEOUT_MS);
|
|
40
|
-
timer.unref?.();
|
|
41
|
-
});
|
|
42
|
-
try {
|
|
43
|
-
await Promise.race([agentClisReady, bound]);
|
|
44
|
-
} finally {
|
|
45
|
-
if (timer) clearTimeout(timer);
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
|
|
49
29
|
export const realAgentFactory: AgentSessionFactory = {
|
|
50
30
|
create: async (args): Promise<AgentSession> => {
|
|
51
31
|
const factory = factoryFor(args.agent.kind);
|
|
@@ -66,7 +46,11 @@ export const realAgentFactory: AgentSessionFactory = {
|
|
|
66
46
|
// at boot the CLI auto-upgrade briefly removes then reinstalls codex/claude,
|
|
67
47
|
// and a resume that races that window dies with "No codex executable found
|
|
68
48
|
// for nodejs X". Post-boot this is already resolved (instant).
|
|
69
|
-
|
|
49
|
+
// Fail closed: the maintenance commands have their own process timeouts
|
|
50
|
+
// and settle this latch even on failure. A second timeout here would let a
|
|
51
|
+
// CLI spawn enter the shared volume while boot reconciliation still owns
|
|
52
|
+
// its remove/reinstall window.
|
|
53
|
+
await agentClisReady;
|
|
70
54
|
return factory.create(args);
|
|
71
55
|
},
|
|
72
56
|
};
|