@runuai/host 0.8.3 → 0.8.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.
- package/images/standard/Dockerfile +26 -9
- package/lib/agents/cursor.ts +324 -0
- package/lib/agents/factory.ts +2 -0
- package/lib/agents/grok.ts +255 -0
- package/lib/standard-image.ts +44 -2
- package/package.json +1 -1
- package/scripts/agent/task-up.sh +15 -0
|
@@ -219,15 +219,32 @@ RUN bash -lc '\
|
|
|
219
219
|
asdf reshim nodejs; \
|
|
220
220
|
'
|
|
221
221
|
|
|
222
|
-
#
|
|
223
|
-
#
|
|
224
|
-
# and
|
|
225
|
-
#
|
|
226
|
-
#
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
'
|
|
222
|
+
# Optional agent CLIs — installed ONLY when the host has them configured
|
|
223
|
+
# (INSTALL_* build args from standard-image.ts, gated on the operator's creds
|
|
224
|
+
# and folded into the rebuild hash, so a new login rebuilds). Single-binary
|
|
225
|
+
# installs to /home/node/.{kimi-code,grok}/bin; the adapters invoke them by
|
|
226
|
+
# absolute path and task-up copies the subscription creds in. Non-fatal: a CDN
|
|
227
|
+
# hiccup must never break the image for Claude/Codex.
|
|
228
|
+
ARG INSTALL_KIMI=0
|
|
229
|
+
RUN if [ "$INSTALL_KIMI" = "1" ]; then \
|
|
230
|
+
bash -lc 'curl -fsSL https://code.kimi.com/kimi-code/install.sh | bash' \
|
|
231
|
+
|| echo "[warn] kimi-code install failed — kimi engine unavailable in this image"; \
|
|
232
|
+
fi
|
|
233
|
+
|
|
234
|
+
ARG INSTALL_GROK=0
|
|
235
|
+
RUN if [ "$INSTALL_GROK" = "1" ]; then \
|
|
236
|
+
bash -lc 'curl -fsSL https://x.ai/cli/install.sh | bash' \
|
|
237
|
+
|| echo "[warn] grok install failed — grok engine unavailable in this image"; \
|
|
238
|
+
fi
|
|
239
|
+
|
|
240
|
+
# Cursor Agent (Anysphere) — installs to /home/node/.local/bin/cursor-agent.
|
|
241
|
+
# Auth is CURSOR_API_KEY (env-injected by the adapter), so nothing to copy at
|
|
242
|
+
# task-up — just the binary.
|
|
243
|
+
ARG INSTALL_CURSOR=0
|
|
244
|
+
RUN if [ "$INSTALL_CURSOR" = "1" ]; then \
|
|
245
|
+
bash -lc 'curl https://cursor.com/install -fsS | bash' \
|
|
246
|
+
|| echo "[warn] cursor install failed — cursor engine unavailable in this image"; \
|
|
247
|
+
fi
|
|
231
248
|
|
|
232
249
|
ENV PATH=/home/node/.local/bin:$PATH
|
|
233
250
|
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CursorSession — a real AgentSession backed by **Cursor Agent** (`cursor-agent`),
|
|
3
|
+
* run inside the task container:
|
|
4
|
+
*
|
|
5
|
+
* docker exec -i -e CURSOR_API_KEY=… task-<id>-app-1 \
|
|
6
|
+
* /home/node/.local/bin/cursor-agent -p "<prompt>" \
|
|
7
|
+
* --output-format stream-json --stream-partial-output --force --trust \
|
|
8
|
+
* -m <model> [--resume <sessionId>]
|
|
9
|
+
*
|
|
10
|
+
* One-shot per turn (like Kimi/Grok); sends serialized; continuity via
|
|
11
|
+
* `--resume <sessionId>` captured from the stream. Cursor's stream-json is
|
|
12
|
+
* essentially Claude Code's — the RICHEST of the extra engines (streaming text
|
|
13
|
+
* AND tool cards):
|
|
14
|
+
*
|
|
15
|
+
* {"type":"system","subtype":"init","session_id":…} → session id
|
|
16
|
+
* {"type":"thinking","subtype":"delta"|"completed",…} → skipped
|
|
17
|
+
* {"type":"assistant","message":{content:[{text}]},"timestamp_ms":…}
|
|
18
|
+
* → message_delta
|
|
19
|
+
* {"type":"tool_call","subtype":"started","call_id","tool_call":{…}}
|
|
20
|
+
* → tool_call card
|
|
21
|
+
* {"type":"result","subtype":"success"|"error","result",session_id}
|
|
22
|
+
* → message_complete + end
|
|
23
|
+
*
|
|
24
|
+
* AUTH is a Cursor API key (`CURSOR_API_KEY`) — env-injected at exec, like the
|
|
25
|
+
* Claude token (no config-dir copy). Cloud never sees it (ADR-015). `-p` runs
|
|
26
|
+
* headless with `--force` (auto-run tools; the container is the sandbox) and
|
|
27
|
+
* `--trust` (skip the workspace-trust prompt). Cursor has no system-prompt
|
|
28
|
+
* flag, so the briefing folds into the first turn's prompt.
|
|
29
|
+
*/
|
|
30
|
+
import { spawn, type ChildProcess } from "node:child_process";
|
|
31
|
+
|
|
32
|
+
import { newId } from "../ulid";
|
|
33
|
+
import { register } from "./registry";
|
|
34
|
+
import type {
|
|
35
|
+
AgentEvent,
|
|
36
|
+
AgentEventHandler,
|
|
37
|
+
AgentKind,
|
|
38
|
+
AgentSession,
|
|
39
|
+
RosterAgent,
|
|
40
|
+
} from "./types";
|
|
41
|
+
|
|
42
|
+
const CURSOR_BIN = "/home/node/.local/bin/cursor-agent";
|
|
43
|
+
|
|
44
|
+
// A curated slice of Cursor's ~32 models (full ids from `cursor-agent
|
|
45
|
+
// --list-models`). `auto` lets Cursor pick; the rest are the frontier. The UI
|
|
46
|
+
// also accepts any typed id (unverified round-trip). UPDATE as Cursor's lineup
|
|
47
|
+
// changes.
|
|
48
|
+
const CURSOR_MODELS = [
|
|
49
|
+
"auto",
|
|
50
|
+
"composer-2.5",
|
|
51
|
+
"claude-opus-4-8-thinking-high",
|
|
52
|
+
"gpt-5.6-sol-high",
|
|
53
|
+
"cursor-grok-4.5-high",
|
|
54
|
+
"claude-fable-5-thinking-high",
|
|
55
|
+
];
|
|
56
|
+
const CURSOR_DEFAULT_MODEL = "auto";
|
|
57
|
+
const CURSOR_EFFORTS: string[] = []; // effort is baked into the model id
|
|
58
|
+
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
// Pure protocol mapping.
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
function assistantText(msg: Record<string, unknown>): string {
|
|
64
|
+
const message = msg.message as { content?: unknown } | undefined;
|
|
65
|
+
const content = Array.isArray(message?.content) ? message!.content : [];
|
|
66
|
+
let out = "";
|
|
67
|
+
for (const raw of content) {
|
|
68
|
+
const block = raw as { type?: unknown; text?: unknown };
|
|
69
|
+
if (block.type === "text" && typeof block.text === "string") out += block.text;
|
|
70
|
+
}
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** "shellToolCall" → "shell", "readFileToolCall" → "readFile". */
|
|
75
|
+
function prettyTool(name: string): string {
|
|
76
|
+
return name.replace(/ToolCall$/, "") || "tool";
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface MappedCursorLine {
|
|
80
|
+
textDelta?: string;
|
|
81
|
+
toolCall?: { id: string; title: string; detail: string };
|
|
82
|
+
end?: boolean;
|
|
83
|
+
finalText?: string;
|
|
84
|
+
sessionId?: string;
|
|
85
|
+
errorText?: string;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function mapCursorLine(line: string): MappedCursorLine {
|
|
89
|
+
const t = line.trim();
|
|
90
|
+
if (!t.startsWith("{")) return {};
|
|
91
|
+
let m: Record<string, unknown>;
|
|
92
|
+
try {
|
|
93
|
+
m = JSON.parse(t) as Record<string, unknown>;
|
|
94
|
+
} catch {
|
|
95
|
+
return {};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (m.type === "system") {
|
|
99
|
+
return typeof m.session_id === "string" ? { sessionId: m.session_id } : {};
|
|
100
|
+
}
|
|
101
|
+
if (m.type === "assistant") {
|
|
102
|
+
// Streaming partials carry timestamp_ms; the final full message (finalized
|
|
103
|
+
// by `result`) does not — only stream the partials to avoid doubling.
|
|
104
|
+
if (typeof m.timestamp_ms === "number") {
|
|
105
|
+
const text = assistantText(m);
|
|
106
|
+
if (text) return { textDelta: text };
|
|
107
|
+
}
|
|
108
|
+
return {};
|
|
109
|
+
}
|
|
110
|
+
if (m.type === "tool_call" && m.subtype === "started") {
|
|
111
|
+
const tc = (m.tool_call ?? {}) as Record<string, unknown>;
|
|
112
|
+
const [name, spec] = Object.entries(tc)[0] ?? ["tool", {}];
|
|
113
|
+
const args = ((spec as Record<string, unknown>)?.args ?? {}) as Record<string, unknown>;
|
|
114
|
+
const pick = args.command ?? args.path ?? args.filePath ?? args.file_path ?? args.query;
|
|
115
|
+
return {
|
|
116
|
+
toolCall: {
|
|
117
|
+
id: typeof m.call_id === "string" ? m.call_id : newId(),
|
|
118
|
+
title: prettyTool(name),
|
|
119
|
+
detail: typeof pick === "string" ? pick : "",
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
if (m.type === "result") {
|
|
124
|
+
const errored = m.is_error === true;
|
|
125
|
+
return {
|
|
126
|
+
end: true,
|
|
127
|
+
sessionId: typeof m.session_id === "string" ? m.session_id : undefined,
|
|
128
|
+
finalText: typeof m.result === "string" ? m.result : undefined,
|
|
129
|
+
errorText: errored
|
|
130
|
+
? typeof m.result === "string"
|
|
131
|
+
? m.result
|
|
132
|
+
: "cursor turn failed"
|
|
133
|
+
: undefined,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
// thinking / user / other → nothing.
|
|
137
|
+
return {};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ---------------------------------------------------------------------------
|
|
141
|
+
// Session
|
|
142
|
+
// ---------------------------------------------------------------------------
|
|
143
|
+
|
|
144
|
+
export type Spawner = (args: string[]) => ChildProcess;
|
|
145
|
+
const defaultSpawn: Spawner = (args) =>
|
|
146
|
+
spawn("docker", args, { stdio: ["ignore", "pipe", "pipe"] });
|
|
147
|
+
|
|
148
|
+
export class CursorSession implements AgentSession {
|
|
149
|
+
readonly agentId: string;
|
|
150
|
+
readonly kind: AgentKind = "cursor";
|
|
151
|
+
|
|
152
|
+
private readonly containerName: string;
|
|
153
|
+
private readonly model?: string;
|
|
154
|
+
private readonly systemPreamble: string;
|
|
155
|
+
private readonly agentEnv: Record<string, string>;
|
|
156
|
+
private readonly spawner: Spawner;
|
|
157
|
+
private readonly apiKey: string;
|
|
158
|
+
|
|
159
|
+
private readonly handlers = new Set<AgentEventHandler>();
|
|
160
|
+
private sessionId: string | null = null;
|
|
161
|
+
private current: ChildProcess | null = null;
|
|
162
|
+
private queue: Promise<void> = Promise.resolve();
|
|
163
|
+
private sentPreamble = false;
|
|
164
|
+
private closed = false;
|
|
165
|
+
|
|
166
|
+
constructor(args: {
|
|
167
|
+
taskId: string;
|
|
168
|
+
agent: RosterAgent;
|
|
169
|
+
containerName: string;
|
|
170
|
+
systemPreamble: string;
|
|
171
|
+
agentEnv?: Record<string, string>;
|
|
172
|
+
spawner?: Spawner;
|
|
173
|
+
apiKey?: string;
|
|
174
|
+
}) {
|
|
175
|
+
this.agentId = args.agent.id;
|
|
176
|
+
this.containerName = args.containerName;
|
|
177
|
+
this.model = args.agent.model;
|
|
178
|
+
this.systemPreamble = args.systemPreamble;
|
|
179
|
+
this.agentEnv = args.agentEnv ?? {};
|
|
180
|
+
this.spawner = args.spawner ?? defaultSpawn;
|
|
181
|
+
this.apiKey = args.apiKey ?? process.env.CURSOR_API_KEY ?? "";
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
onEvent(handler: AgentEventHandler): () => void {
|
|
185
|
+
this.handlers.add(handler);
|
|
186
|
+
return () => this.handlers.delete(handler);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
private emit(event: AgentEvent): void {
|
|
190
|
+
if (this.closed && event.type !== "exit") return;
|
|
191
|
+
for (const h of this.handlers) h(event);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async send(text: string): Promise<void> {
|
|
195
|
+
if (this.closed) return;
|
|
196
|
+
this.queue = this.queue.then(() => this.runTurn(text));
|
|
197
|
+
return this.queue;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
private buildArgs(prompt: string): string[] {
|
|
201
|
+
const args = ["exec", "-i", "-u", "node", "-e", `CURSOR_API_KEY=${this.apiKey}`];
|
|
202
|
+
for (const [k, v] of Object.entries(this.agentEnv)) {
|
|
203
|
+
args.push("-e", `${k}=${v}`);
|
|
204
|
+
}
|
|
205
|
+
args.push(
|
|
206
|
+
this.containerName,
|
|
207
|
+
CURSOR_BIN,
|
|
208
|
+
"-p",
|
|
209
|
+
prompt,
|
|
210
|
+
"--output-format",
|
|
211
|
+
"stream-json",
|
|
212
|
+
"--stream-partial-output",
|
|
213
|
+
"--force", // container is the sandbox — auto-run tools
|
|
214
|
+
"--trust", // skip the workspace-trust prompt in headless
|
|
215
|
+
);
|
|
216
|
+
if (this.model && this.model !== "auto") args.push("-m", this.model);
|
|
217
|
+
if (this.sessionId) args.push("--resume", this.sessionId);
|
|
218
|
+
return args;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
private async runTurn(text: string): Promise<void> {
|
|
222
|
+
if (this.closed) return;
|
|
223
|
+
// No system-prompt flag — fold the briefing into the first turn; later
|
|
224
|
+
// turns carry it via the resumed session.
|
|
225
|
+
const prompt =
|
|
226
|
+
!this.sentPreamble && this.systemPreamble.trim().length > 0
|
|
227
|
+
? `${this.systemPreamble}\n\n---\n\n${text}`
|
|
228
|
+
: text;
|
|
229
|
+
this.sentPreamble = true;
|
|
230
|
+
|
|
231
|
+
const child = this.spawner(this.buildArgs(prompt));
|
|
232
|
+
this.current = child;
|
|
233
|
+
|
|
234
|
+
let acc = "";
|
|
235
|
+
let sawText = false;
|
|
236
|
+
let buf = "";
|
|
237
|
+
const consume = (chunk: string): void => {
|
|
238
|
+
buf += chunk;
|
|
239
|
+
let nl: number;
|
|
240
|
+
while ((nl = buf.indexOf("\n")) >= 0) {
|
|
241
|
+
const line = buf.slice(0, nl);
|
|
242
|
+
buf = buf.slice(nl + 1);
|
|
243
|
+
const m = mapCursorLine(line);
|
|
244
|
+
if (m.sessionId) this.sessionId = m.sessionId;
|
|
245
|
+
if (typeof m.textDelta === "string") {
|
|
246
|
+
sawText = true;
|
|
247
|
+
acc += m.textDelta;
|
|
248
|
+
this.emit({ type: "message_delta", text: m.textDelta });
|
|
249
|
+
}
|
|
250
|
+
if (m.toolCall) {
|
|
251
|
+
this.emit({
|
|
252
|
+
type: "tool_call",
|
|
253
|
+
id: m.toolCall.id,
|
|
254
|
+
title: m.toolCall.title,
|
|
255
|
+
detail: m.toolCall.detail,
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
if (m.end) {
|
|
259
|
+
if (m.errorText) this.emit({ type: "error", message: m.errorText });
|
|
260
|
+
const finalText = m.finalText ?? acc;
|
|
261
|
+
if (sawText || finalText) {
|
|
262
|
+
this.emit({ type: "message_complete", text: finalText });
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
};
|
|
267
|
+
child.stdout?.on("data", (b: Buffer) => consume(b.toString("utf8")));
|
|
268
|
+
let stderr = "";
|
|
269
|
+
child.stderr?.on("data", (b: Buffer) => {
|
|
270
|
+
stderr += b.toString("utf8");
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
await new Promise<void>((resolve) => {
|
|
274
|
+
const finish = (code: number | null, spawnErr?: string): void => {
|
|
275
|
+
this.current = null;
|
|
276
|
+
if (buf.trim().length > 0) consume("\n");
|
|
277
|
+
if (this.closed) return resolve();
|
|
278
|
+
if (spawnErr) {
|
|
279
|
+
this.emit({ type: "error", message: `cursor spawn failed: ${spawnErr}` });
|
|
280
|
+
} else if (code !== 0) {
|
|
281
|
+
const tail = stderr.trim().slice(-500);
|
|
282
|
+
this.emit({
|
|
283
|
+
type: "error",
|
|
284
|
+
message: `cursor exited ${code ?? "null"}${tail ? `: ${tail}` : ""}`,
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
this.emit({ type: "turn_complete" });
|
|
288
|
+
resolve();
|
|
289
|
+
};
|
|
290
|
+
child.on("exit", (code) => finish(code));
|
|
291
|
+
child.on("error", (err) => finish(null, err.message));
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
async interrupt(): Promise<void> {
|
|
296
|
+
this.current?.kill("SIGKILL");
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
async resolvePermission(): Promise<void> {
|
|
300
|
+
/* --force auto-approves; no permission requests are emitted */
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
async close(): Promise<void> {
|
|
304
|
+
if (this.closed) return;
|
|
305
|
+
this.closed = true;
|
|
306
|
+
this.current?.kill("SIGKILL");
|
|
307
|
+
this.current = null;
|
|
308
|
+
this.emit({ type: "exit", code: 0 });
|
|
309
|
+
this.handlers.clear();
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
register({
|
|
314
|
+
kind: "cursor",
|
|
315
|
+
label: "Cursor",
|
|
316
|
+
supportedModels: () => [...CURSOR_MODELS],
|
|
317
|
+
defaultModel: CURSOR_DEFAULT_MODEL,
|
|
318
|
+
supportedEfforts: () => [...CURSOR_EFFORTS],
|
|
319
|
+
// Gated on a Cursor API key in the host env (loaded from .env.local); the
|
|
320
|
+
// adapter injects it into the container. No key → engine not advertised.
|
|
321
|
+
available: () => Boolean(process.env.CURSOR_API_KEY),
|
|
322
|
+
create: async ({ taskId, agent, containerName, systemPreamble, agentEnv }) =>
|
|
323
|
+
new CursorSession({ taskId, agent, containerName, systemPreamble, agentEnv }),
|
|
324
|
+
});
|
package/lib/agents/factory.ts
CHANGED
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GrokSession — a real AgentSession backed by xAI's **Grok** CLI, run inside
|
|
3
|
+
* the task container:
|
|
4
|
+
*
|
|
5
|
+
* docker exec -i task-<id>-app-1 ~/.grok/bin/grok -p "<prompt>" \
|
|
6
|
+
* --output-format streaming-json --permission-mode bypassPermissions \
|
|
7
|
+
* -m grok-4.5 --system-prompt-override "<briefing>" [-r <sessionId>]
|
|
8
|
+
*
|
|
9
|
+
* Like Kimi (and unlike Claude's persistent stdin / Codex's app-server), Grok
|
|
10
|
+
* headless mode is **one-shot per turn**: `-p` runs one prompt to completion,
|
|
11
|
+
* streams newline-delimited JSON, and exits. Each `send()` spawns a fresh
|
|
12
|
+
* `docker exec`; sends are **serialized** so a second message can't race a
|
|
13
|
+
* running turn. Continuity is `-r <sessionId>` — captured from the turn-end
|
|
14
|
+
* event (per-agent isolation, since each session tracks its own id).
|
|
15
|
+
*
|
|
16
|
+
* Stream-json vocabulary (`--output-format streaming-json`, verified):
|
|
17
|
+
* {"type":"thought","data":"…"} → skipped (internal reasoning)
|
|
18
|
+
* {"type":"text","data":"…"} → message_delta (streamed)
|
|
19
|
+
* {"type":"end","stopReason","sessionId",…} → message_complete + turn end
|
|
20
|
+
* Tool calls happen (num_turns>1) but aren't surfaced as events in headless
|
|
21
|
+
* mode, so there are no tool cards — the agent works, you get the streamed
|
|
22
|
+
* answer.
|
|
23
|
+
*
|
|
24
|
+
* AUTH is the operator's Grok subscription: `~/.grok/{auth.json, config.toml}`
|
|
25
|
+
* is docker-cp'd into the container at task-up (the Codex/Kimi config-dir
|
|
26
|
+
* pattern; the macOS `bin/` is NOT copied — the image supplies the Linux
|
|
27
|
+
* binary). auth.json is an OIDC token with a refresh_token, so the CLI renews
|
|
28
|
+
* it in-container. Cloud never sees it (ADR-015). `--system-prompt-override`
|
|
29
|
+
* carries the channel briefing (no fold-into-turn-1 needed).
|
|
30
|
+
*/
|
|
31
|
+
import { spawn, type ChildProcess } from "node:child_process";
|
|
32
|
+
import { existsSync } from "node:fs";
|
|
33
|
+
import { homedir } from "node:os";
|
|
34
|
+
import { join } from "node:path";
|
|
35
|
+
|
|
36
|
+
import { register } from "./registry";
|
|
37
|
+
import type {
|
|
38
|
+
AgentEvent,
|
|
39
|
+
AgentEventHandler,
|
|
40
|
+
AgentKind,
|
|
41
|
+
AgentSession,
|
|
42
|
+
RosterAgent,
|
|
43
|
+
} from "./types";
|
|
44
|
+
|
|
45
|
+
const GROK_BIN = "/home/node/.grok/bin/grok";
|
|
46
|
+
|
|
47
|
+
// `grok models` lists what the account can run. grok-4.5 is the current
|
|
48
|
+
// default; UPDATE WHEN xAI CHANGES the lineup.
|
|
49
|
+
const GROK_MODELS = ["grok-4.5"];
|
|
50
|
+
const GROK_DEFAULT_MODEL = "grok-4.5";
|
|
51
|
+
// Grok has no per-invocation reasoning-effort flag.
|
|
52
|
+
const GROK_EFFORTS: string[] = [];
|
|
53
|
+
|
|
54
|
+
function grokAuthPath(): string {
|
|
55
|
+
return join(process.env.UAI_OWNER_HOME?.trim() || homedir(), ".grok", "auth.json");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
// Pure protocol mapping — one streaming-json line → deltas / end.
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
export interface MappedGrokLine {
|
|
63
|
+
/** A streamed text chunk (assistant answer), if this line carried one. */
|
|
64
|
+
textDelta?: string;
|
|
65
|
+
/** True on the turn-end event. */
|
|
66
|
+
end?: boolean;
|
|
67
|
+
/** The session id from the end event, for `-r` on the next turn. */
|
|
68
|
+
sessionId?: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function mapGrokLine(line: string): MappedGrokLine {
|
|
72
|
+
const trimmed = line.trim();
|
|
73
|
+
if (!trimmed.startsWith("{")) return {};
|
|
74
|
+
let msg: Record<string, unknown>;
|
|
75
|
+
try {
|
|
76
|
+
msg = JSON.parse(trimmed) as Record<string, unknown>;
|
|
77
|
+
} catch {
|
|
78
|
+
return {};
|
|
79
|
+
}
|
|
80
|
+
if (msg.type === "text" && typeof msg.data === "string") {
|
|
81
|
+
return { textDelta: msg.data };
|
|
82
|
+
}
|
|
83
|
+
if (msg.type === "end") {
|
|
84
|
+
return {
|
|
85
|
+
end: true,
|
|
86
|
+
sessionId: typeof msg.sessionId === "string" ? msg.sessionId : undefined,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
// "thought" (reasoning) and any other types: no user-facing event.
|
|
90
|
+
return {};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
// Session
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
export type Spawner = (args: string[]) => ChildProcess;
|
|
98
|
+
const defaultSpawn: Spawner = (args) =>
|
|
99
|
+
spawn("docker", args, { stdio: ["ignore", "pipe", "pipe"] });
|
|
100
|
+
|
|
101
|
+
export class GrokSession implements AgentSession {
|
|
102
|
+
readonly agentId: string;
|
|
103
|
+
readonly kind: AgentKind = "grok";
|
|
104
|
+
|
|
105
|
+
private readonly containerName: string;
|
|
106
|
+
private readonly model?: string;
|
|
107
|
+
private readonly systemPreamble: string;
|
|
108
|
+
private readonly agentEnv: Record<string, string>;
|
|
109
|
+
private readonly spawner: Spawner;
|
|
110
|
+
|
|
111
|
+
private readonly handlers = new Set<AgentEventHandler>();
|
|
112
|
+
private sessionId: string | null = null;
|
|
113
|
+
private current: ChildProcess | null = null;
|
|
114
|
+
private queue: Promise<void> = Promise.resolve();
|
|
115
|
+
private closed = false;
|
|
116
|
+
|
|
117
|
+
constructor(args: {
|
|
118
|
+
taskId: string;
|
|
119
|
+
agent: RosterAgent;
|
|
120
|
+
containerName: string;
|
|
121
|
+
systemPreamble: string;
|
|
122
|
+
agentEnv?: Record<string, string>;
|
|
123
|
+
spawner?: Spawner;
|
|
124
|
+
}) {
|
|
125
|
+
this.agentId = args.agent.id;
|
|
126
|
+
this.containerName = args.containerName;
|
|
127
|
+
this.model = args.agent.model;
|
|
128
|
+
this.systemPreamble = args.systemPreamble;
|
|
129
|
+
this.agentEnv = args.agentEnv ?? {};
|
|
130
|
+
this.spawner = args.spawner ?? defaultSpawn;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
onEvent(handler: AgentEventHandler): () => void {
|
|
134
|
+
this.handlers.add(handler);
|
|
135
|
+
return () => this.handlers.delete(handler);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
private emit(event: AgentEvent): void {
|
|
139
|
+
if (this.closed && event.type !== "exit") return;
|
|
140
|
+
for (const h of this.handlers) h(event);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async send(text: string): Promise<void> {
|
|
144
|
+
if (this.closed) return;
|
|
145
|
+
this.queue = this.queue.then(() => this.runTurn(text));
|
|
146
|
+
return this.queue;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
private buildArgs(prompt: string): string[] {
|
|
150
|
+
const args = ["exec", "-i", "-u", "node"];
|
|
151
|
+
for (const [k, v] of Object.entries(this.agentEnv)) {
|
|
152
|
+
args.push("-e", `${k}=${v}`);
|
|
153
|
+
}
|
|
154
|
+
args.push(
|
|
155
|
+
this.containerName,
|
|
156
|
+
GROK_BIN,
|
|
157
|
+
"-p",
|
|
158
|
+
prompt,
|
|
159
|
+
"--output-format",
|
|
160
|
+
"streaming-json",
|
|
161
|
+
// The container is the isolation boundary — auto-run tools.
|
|
162
|
+
"--permission-mode",
|
|
163
|
+
"bypassPermissions",
|
|
164
|
+
);
|
|
165
|
+
if (this.model) args.push("-m", this.model);
|
|
166
|
+
if (this.systemPreamble.trim().length > 0) {
|
|
167
|
+
args.push("--system-prompt-override", this.systemPreamble);
|
|
168
|
+
}
|
|
169
|
+
// Resume the same conversation across turns (captured from the last end).
|
|
170
|
+
if (this.sessionId) args.push("-r", this.sessionId);
|
|
171
|
+
return args;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
private async runTurn(text: string): Promise<void> {
|
|
175
|
+
if (this.closed) return;
|
|
176
|
+
const child = this.spawner(this.buildArgs(text));
|
|
177
|
+
this.current = child;
|
|
178
|
+
|
|
179
|
+
let acc = "";
|
|
180
|
+
let sawText = false;
|
|
181
|
+
let buf = "";
|
|
182
|
+
const consume = (chunk: string): void => {
|
|
183
|
+
buf += chunk;
|
|
184
|
+
let nl: number;
|
|
185
|
+
while ((nl = buf.indexOf("\n")) >= 0) {
|
|
186
|
+
const line = buf.slice(0, nl);
|
|
187
|
+
buf = buf.slice(nl + 1);
|
|
188
|
+
const m = mapGrokLine(line);
|
|
189
|
+
if (m.sessionId) this.sessionId = m.sessionId;
|
|
190
|
+
if (typeof m.textDelta === "string") {
|
|
191
|
+
sawText = true;
|
|
192
|
+
acc += m.textDelta;
|
|
193
|
+
this.emit({ type: "message_delta", text: m.textDelta });
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
child.stdout?.on("data", (b: Buffer) => consume(b.toString("utf8")));
|
|
198
|
+
let stderr = "";
|
|
199
|
+
child.stderr?.on("data", (b: Buffer) => {
|
|
200
|
+
stderr += b.toString("utf8");
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
await new Promise<void>((resolve) => {
|
|
204
|
+
const finish = (code: number | null, spawnErr?: string): void => {
|
|
205
|
+
this.current = null;
|
|
206
|
+
if (buf.trim().length > 0) consume("\n"); // flush trailing line
|
|
207
|
+
if (this.closed) return resolve();
|
|
208
|
+
if (spawnErr) {
|
|
209
|
+
this.emit({ type: "error", message: `grok spawn failed: ${spawnErr}` });
|
|
210
|
+
} else if (code !== 0) {
|
|
211
|
+
const tail = stderr.trim().slice(-500);
|
|
212
|
+
this.emit({
|
|
213
|
+
type: "error",
|
|
214
|
+
message: `grok exited ${code ?? "null"}${tail ? `: ${tail}` : ""}`,
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
// Finalize the streamed message (no-op text if the turn produced none).
|
|
218
|
+
if (sawText) this.emit({ type: "message_complete", text: acc });
|
|
219
|
+
this.emit({ type: "turn_complete" });
|
|
220
|
+
resolve();
|
|
221
|
+
};
|
|
222
|
+
child.on("exit", (code) => finish(code));
|
|
223
|
+
child.on("error", (err) => finish(null, err.message));
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async interrupt(): Promise<void> {
|
|
228
|
+
this.current?.kill("SIGKILL");
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// bypassPermissions auto-approves; no permission requests are emitted.
|
|
232
|
+
async resolvePermission(): Promise<void> {
|
|
233
|
+
/* no-op */
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async close(): Promise<void> {
|
|
237
|
+
if (this.closed) return;
|
|
238
|
+
this.closed = true;
|
|
239
|
+
this.current?.kill("SIGKILL");
|
|
240
|
+
this.current = null;
|
|
241
|
+
this.emit({ type: "exit", code: 0 });
|
|
242
|
+
this.handlers.clear();
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
register({
|
|
247
|
+
kind: "grok",
|
|
248
|
+
label: "Grok",
|
|
249
|
+
supportedModels: () => [...GROK_MODELS],
|
|
250
|
+
defaultModel: GROK_DEFAULT_MODEL,
|
|
251
|
+
supportedEfforts: () => [...GROK_EFFORTS],
|
|
252
|
+
available: () => existsSync(grokAuthPath()),
|
|
253
|
+
create: async ({ taskId, agent, containerName, systemPreamble, agentEnv }) =>
|
|
254
|
+
new GrokSession({ taskId, agent, containerName, systemPreamble, agentEnv }),
|
|
255
|
+
});
|
package/lib/standard-image.ts
CHANGED
|
@@ -18,7 +18,9 @@
|
|
|
18
18
|
|
|
19
19
|
import { spawn } from "node:child_process";
|
|
20
20
|
import { createHash } from "node:crypto";
|
|
21
|
+
import { existsSync } from "node:fs";
|
|
21
22
|
import { readdir, readFile } from "node:fs/promises";
|
|
23
|
+
import { homedir } from "node:os";
|
|
22
24
|
import { dirname, join, resolve } from "node:path";
|
|
23
25
|
import { fileURLToPath } from "node:url";
|
|
24
26
|
|
|
@@ -67,7 +69,29 @@ const CONTEXT_HASH_LABEL = "com.runuai.context-hash";
|
|
|
67
69
|
* by relative path). Null when the context can't be read — the caller then
|
|
68
70
|
* keeps whatever image exists.
|
|
69
71
|
*/
|
|
70
|
-
|
|
72
|
+
/**
|
|
73
|
+
* Which OPTIONAL agent CLIs the operator has actually configured — so the
|
|
74
|
+
* image installs only those, not every engine on every host. Keyed on the
|
|
75
|
+
* same credential each adapter's `available()` checks (kimi/grok are copied
|
|
76
|
+
* into containers at task-up; claude/codex are always baked). Folded into the
|
|
77
|
+
* build hash below, so logging into a new engine triggers a rebuild.
|
|
78
|
+
*/
|
|
79
|
+
export function configuredOptionalEngines(): {
|
|
80
|
+
kimi: boolean;
|
|
81
|
+
grok: boolean;
|
|
82
|
+
cursor: boolean;
|
|
83
|
+
} {
|
|
84
|
+
const home = process.env.UAI_OWNER_HOME?.trim() || homedir();
|
|
85
|
+
return {
|
|
86
|
+
kimi: existsSync(join(home, ".kimi-code", "credentials", "kimi-code.json")),
|
|
87
|
+
grok: existsSync(join(home, ".grok", "auth.json")),
|
|
88
|
+
// Cursor auths via CURSOR_API_KEY (env, loaded from .env.local), not a
|
|
89
|
+
// config file — install it when the key is present.
|
|
90
|
+
cursor: Boolean(process.env.CURSOR_API_KEY),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function hashBuildContext(extra = ""): Promise<string | null> {
|
|
71
95
|
try {
|
|
72
96
|
const root = standardImageDir();
|
|
73
97
|
const files: string[] = [];
|
|
@@ -87,6 +111,10 @@ async function hashBuildContext(): Promise<string | null> {
|
|
|
87
111
|
hash.update(await readFile(join(root, rel)));
|
|
88
112
|
hash.update("\0");
|
|
89
113
|
}
|
|
114
|
+
// Build args (which optional engines are installed) are part of the image
|
|
115
|
+
// identity — a config change must invalidate the label so it rebuilds.
|
|
116
|
+
hash.update(extra);
|
|
117
|
+
hash.update("\0");
|
|
90
118
|
return hash.digest("hex").slice(0, 32);
|
|
91
119
|
} catch {
|
|
92
120
|
return null;
|
|
@@ -304,7 +332,20 @@ export async function ensureStandardImage(): Promise<void> {
|
|
|
304
332
|
// landed). The build context is content-hashed into an image label;
|
|
305
333
|
// a mismatch triggers a rebuild (layer cache keeps it cheap).
|
|
306
334
|
let imageReady = false;
|
|
307
|
-
|
|
335
|
+
// Install only the optional engines the operator has configured; the flags
|
|
336
|
+
// are build args AND part of the content hash (so a new login rebuilds).
|
|
337
|
+
const engines = configuredOptionalEngines();
|
|
338
|
+
const engineArgs = [
|
|
339
|
+
"--build-arg",
|
|
340
|
+
`INSTALL_KIMI=${engines.kimi ? 1 : 0}`,
|
|
341
|
+
"--build-arg",
|
|
342
|
+
`INSTALL_GROK=${engines.grok ? 1 : 0}`,
|
|
343
|
+
"--build-arg",
|
|
344
|
+
`INSTALL_CURSOR=${engines.cursor ? 1 : 0}`,
|
|
345
|
+
];
|
|
346
|
+
const contextHash = await hashBuildContext(
|
|
347
|
+
`kimi=${engines.kimi ? 1 : 0};grok=${engines.grok ? 1 : 0};cursor=${engines.cursor ? 1 : 0}`,
|
|
348
|
+
);
|
|
308
349
|
const inspect = await run("docker", [
|
|
309
350
|
"image",
|
|
310
351
|
"inspect",
|
|
@@ -338,6 +379,7 @@ export async function ensureStandardImage(): Promise<void> {
|
|
|
338
379
|
"build",
|
|
339
380
|
"-t",
|
|
340
381
|
STANDARD_IMAGE_TAG,
|
|
382
|
+
...engineArgs,
|
|
341
383
|
...(contextHash !== null
|
|
342
384
|
? ["--label", `${CONTEXT_HASH_LABEL}=${contextHash}`]
|
|
343
385
|
: []),
|
package/package.json
CHANGED
package/scripts/agent/task-up.sh
CHANGED
|
@@ -492,6 +492,21 @@ fi
|
|
|
492
492
|
docker exec -u root "$app_container" \
|
|
493
493
|
chown -R node:node /home/node/.kimi-code >/dev/null 2>&1 || true
|
|
494
494
|
|
|
495
|
+
# Copy Grok subscription/config into a task-private /home/node/.grok. The Linux
|
|
496
|
+
# `grok` binary is baked into the image; here we copy the arch-independent
|
|
497
|
+
# auth + config (auth.json is an OIDC token the CLI refreshes in-container).
|
|
498
|
+
docker exec -u root "$app_container" \
|
|
499
|
+
mkdir -p /home/node/.grok >/dev/null 2>&1 || true
|
|
500
|
+
for grok_item in auth.json config.toml models_cache.json agent_id; do
|
|
501
|
+
if [ -e "$UAI_OWNER_HOME/.grok/$grok_item" ]; then
|
|
502
|
+
docker cp "$UAI_OWNER_HOME/.grok/$grok_item" \
|
|
503
|
+
"$app_container":/home/node/.grok/ >/dev/null 2>&1 \
|
|
504
|
+
|| log "warning: docker cp of .grok/$grok_item failed; grok may need re-login"
|
|
505
|
+
fi
|
|
506
|
+
done
|
|
507
|
+
docker exec -u root "$app_container" \
|
|
508
|
+
chown -R node:node /home/node/.grok >/dev/null 2>&1 || true
|
|
509
|
+
|
|
495
510
|
# Copy the same resolved SSH identity (task creator's per-user key when present,
|
|
496
511
|
# else the operator identity — see above) into the container, so the agent signs
|
|
497
512
|
# + pushes with the key whose .pub the user registered on GitHub. ADR-027 drops
|