@runuai/host 0.8.2 → 0.8.4

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.
@@ -219,6 +219,24 @@ RUN bash -lc '\
219
219
  asdf reshim nodejs; \
220
220
  '
221
221
 
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
+
222
240
  ENV PATH=/home/node/.local/bin:$PATH
223
241
 
224
242
  # ---------------------------------------------------------------------------
@@ -17,6 +17,8 @@
17
17
  // Side-effect imports: register the built-in adapters with the registry.
18
18
  import "./claude";
19
19
  import "./codex";
20
+ import "./kimi";
21
+ import "./grok";
20
22
 
21
23
  import { factoryFor } from "./registry";
22
24
  import type { AgentSession, AgentSessionFactory } from "./types";
@@ -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
+ });
@@ -0,0 +1,313 @@
1
+ /**
2
+ * KimiSession — a real AgentSession backed by Moonshot's **Kimi Code** CLI,
3
+ * run inside the task container:
4
+ *
5
+ * docker exec -i task-<id>-app-1 ~/.kimi-code/bin/kimi \
6
+ * -p "<prompt>" -m kimi-code/k3 --output-format stream-json [-r <sessionId>]
7
+ *
8
+ * Unlike Claude (persistent stdin session) and Codex (`app-server` JSON-RPC),
9
+ * Kimi Code is **one-shot per turn**: `-p` runs a single prompt to completion,
10
+ * streams newline-delimited JSON to stdout, and exits. Multi-turn continuity is
11
+ * `-r <sessionId>` (the session lives in Kimi's cloud + the container's
12
+ * ~/.kimi-code). So each `send()` spawns a fresh `docker exec`; sends are
13
+ * serialized so a second message can't race a running turn.
14
+ *
15
+ * Stream-json vocabulary (reverse-engineered — OpenAI-chat-style role lines):
16
+ * {"role":"assistant","content":"…"} → message_complete
17
+ * {"role":"assistant","tool_calls":[{function:{name,arguments}}]} → tool_call
18
+ * {"role":"tool","tool_call_id":…,"content":…} → (no event; folded)
19
+ * {"role":"meta","type":"session.resume_hint","session_id"} → captured for -r
20
+ *
21
+ * AUTH is the operator's Kimi Code **subscription**: `~/.kimi-code` (config +
22
+ * credentials/kimi-code.json) is docker-cp'd into the container at task-up
23
+ * (task-up.sh), same pattern as Codex's ~/.codex — never the cloud (ADR-015).
24
+ * `-p` runs non-interactively and auto-runs tools (the container is the
25
+ * isolation boundary), so there are no permission prompts to resolve.
26
+ */
27
+ import { spawn, type ChildProcess } from "node:child_process";
28
+ import { existsSync } from "node:fs";
29
+ import { homedir } from "node:os";
30
+ import { join } from "node:path";
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
+ /** Installed by the image's install.sh (code.kimi.com); absolute so a
43
+ * non-login `docker exec` doesn't depend on PATH. */
44
+ const KIMI_BIN = "/home/node/.kimi-code/bin/kimi";
45
+
46
+ // Model ids from `~/.kimi-code/config.toml` (`kimi -m <id>`). k3 is the
47
+ // frontier model; the K2.7 coding pair are cheaper/faster. UPDATE WHEN KIMI
48
+ // CHANGES its lineup. Order = display order in the cloud picker.
49
+ const KIMI_MODELS = [
50
+ "kimi-code/k3",
51
+ "kimi-code/kimi-for-coding",
52
+ "kimi-code/kimi-for-coding-highspeed",
53
+ ];
54
+ const KIMI_DEFAULT_MODEL = "kimi-code/k3";
55
+
56
+ // Kimi Code has no per-invocation reasoning-effort flag (effort is a
57
+ // config.toml model default), so nothing to offer in the picker.
58
+ const KIMI_EFFORTS: string[] = [];
59
+
60
+ /** Host path to the subscription credential — its presence gates the engine
61
+ * (mirrors Codex's ~/.codex/auth.json check). */
62
+ function kimiCredPath(): string {
63
+ return join(
64
+ process.env.UAI_OWNER_HOME?.trim() || homedir(),
65
+ ".kimi-code",
66
+ "credentials",
67
+ "kimi-code.json",
68
+ );
69
+ }
70
+
71
+ // ---------------------------------------------------------------------------
72
+ // Pure protocol mapping — one stream-json line → AgentEvent[] (+ session id).
73
+ // ---------------------------------------------------------------------------
74
+
75
+ /** Best-effort one-line detail for a tool card from the tool's JSON args. */
76
+ function toolDetail(rawArgs: unknown): string {
77
+ if (typeof rawArgs !== "string") return "";
78
+ try {
79
+ const a = JSON.parse(rawArgs) as Record<string, unknown>;
80
+ const pick = a.command ?? a.path ?? a.file_path ?? a.pattern ?? a.query;
81
+ return typeof pick === "string" ? pick : rawArgs;
82
+ } catch {
83
+ return rawArgs;
84
+ }
85
+ }
86
+
87
+ export interface MappedLine {
88
+ events: AgentEvent[];
89
+ /** A session.resume_hint id, when this line carried one. */
90
+ sessionId?: string;
91
+ }
92
+
93
+ /**
94
+ * Map a single Kimi stream-json line. Non-JSON lines (a tool's raw stdout that
95
+ * Kimi echoes) and lines with no user-facing meaning (`role:"tool"` results,
96
+ * `role:"user"` echoes) map to no events.
97
+ */
98
+ export function mapKimiLine(line: string): MappedLine {
99
+ const trimmed = line.trim();
100
+ if (!trimmed.startsWith("{")) return { events: [] };
101
+ let msg: Record<string, unknown>;
102
+ try {
103
+ msg = JSON.parse(trimmed) as Record<string, unknown>;
104
+ } catch {
105
+ return { events: [] };
106
+ }
107
+
108
+ if (msg.role === "assistant") {
109
+ if (typeof msg.content === "string" && msg.content.length > 0) {
110
+ return { events: [{ type: "message_complete", text: msg.content }] };
111
+ }
112
+ if (Array.isArray(msg.tool_calls)) {
113
+ const events: AgentEvent[] = [];
114
+ for (const raw of msg.tool_calls) {
115
+ const tc = (raw ?? {}) as Record<string, unknown>;
116
+ const fn = (tc.function ?? {}) as Record<string, unknown>;
117
+ const name = typeof fn.name === "string" ? fn.name : "tool";
118
+ events.push({
119
+ type: "tool_call",
120
+ id: typeof tc.id === "string" ? tc.id : newId(),
121
+ title: name,
122
+ detail: toolDetail(fn.arguments),
123
+ });
124
+ }
125
+ return { events };
126
+ }
127
+ return { events: [] };
128
+ }
129
+
130
+ if (
131
+ msg.role === "meta" &&
132
+ msg.type === "session.resume_hint" &&
133
+ typeof msg.session_id === "string"
134
+ ) {
135
+ return { events: [], sessionId: msg.session_id };
136
+ }
137
+
138
+ return { events: [] };
139
+ }
140
+
141
+ // ---------------------------------------------------------------------------
142
+ // Session
143
+ // ---------------------------------------------------------------------------
144
+
145
+ /** Injectable spawn seam (tests provide a fake). */
146
+ export type Spawner = (args: string[]) => ChildProcess;
147
+ const defaultSpawn: Spawner = (args) =>
148
+ spawn("docker", args, { stdio: ["ignore", "pipe", "pipe"] });
149
+
150
+ export class KimiSession implements AgentSession {
151
+ readonly agentId: string;
152
+ readonly kind: AgentKind = "kimi";
153
+
154
+ private readonly containerName: string;
155
+ private readonly model?: string;
156
+ private readonly systemPreamble: string;
157
+ private readonly agentEnv: Record<string, string>;
158
+ private readonly spawner: Spawner;
159
+
160
+ private readonly handlers = new Set<AgentEventHandler>();
161
+ private sessionId: string | null = null;
162
+ private current: ChildProcess | null = null;
163
+ private queue: Promise<void> = Promise.resolve();
164
+ private sentPreamble = false;
165
+ private closed = false;
166
+
167
+ constructor(args: {
168
+ taskId: string;
169
+ agent: RosterAgent;
170
+ containerName: string;
171
+ systemPreamble: string;
172
+ agentEnv?: Record<string, string>;
173
+ spawner?: Spawner;
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
+ }
182
+
183
+ onEvent(handler: AgentEventHandler): () => void {
184
+ this.handlers.add(handler);
185
+ return () => this.handlers.delete(handler);
186
+ }
187
+
188
+ private emit(event: AgentEvent): void {
189
+ if (this.closed && event.type !== "exit") return;
190
+ for (const h of this.handlers) h(event);
191
+ }
192
+
193
+ /** Kimi is one-shot; serialize turns so a second message queues behind the
194
+ * running one instead of spawning a racing `kimi -p`. */
195
+ async send(text: string): Promise<void> {
196
+ if (this.closed) return;
197
+ this.queue = this.queue.then(() => this.runTurn(text));
198
+ return this.queue;
199
+ }
200
+
201
+ private buildArgs(prompt: string): string[] {
202
+ const args = ["exec", "-i", "-u", "node"];
203
+ for (const [k, v] of Object.entries(this.agentEnv)) {
204
+ args.push("-e", `${k}=${v}`);
205
+ }
206
+ args.push(
207
+ this.containerName,
208
+ KIMI_BIN,
209
+ "-p",
210
+ prompt,
211
+ "--output-format",
212
+ "stream-json",
213
+ );
214
+ if (this.model) args.push("-m", this.model);
215
+ // Continue the same Kimi session across turns (captured from the first
216
+ // turn's resume_hint). First turn has none → a fresh session.
217
+ if (this.sessionId) args.push("-r", this.sessionId);
218
+ return args;
219
+ }
220
+
221
+ private async runTurn(text: string): Promise<void> {
222
+ if (this.closed) return;
223
+ // Kimi's `-p` has no system-prompt flag, so fold the channel briefing into
224
+ // the FIRST turn's prompt (later 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 buf = "";
235
+ const consume = (chunk: string): void => {
236
+ buf += chunk;
237
+ let nl: number;
238
+ while ((nl = buf.indexOf("\n")) >= 0) {
239
+ const line = buf.slice(0, nl);
240
+ buf = buf.slice(nl + 1);
241
+ const { events, sessionId } = mapKimiLine(line);
242
+ if (sessionId) this.sessionId = sessionId;
243
+ for (const e of events) this.emit(e);
244
+ }
245
+ };
246
+ child.stdout?.on("data", (b: Buffer) => consume(b.toString("utf8")));
247
+ let stderr = "";
248
+ child.stderr?.on("data", (b: Buffer) => {
249
+ stderr += b.toString("utf8");
250
+ });
251
+
252
+ await new Promise<void>((resolve) => {
253
+ child.on("exit", (code) => {
254
+ this.current = null;
255
+ if (buf.trim().length > 0) consume("\n"); // flush a trailing line
256
+ if (!this.closed) {
257
+ if (code !== 0) {
258
+ const tail = stderr.trim().slice(-500);
259
+ this.emit({
260
+ type: "error",
261
+ message: `kimi exited ${code ?? "null"}${tail ? `: ${tail}` : ""}`,
262
+ });
263
+ }
264
+ this.emit({ type: "turn_complete" });
265
+ }
266
+ resolve();
267
+ });
268
+ child.on("error", (err) => {
269
+ this.current = null;
270
+ if (!this.closed) {
271
+ this.emit({ type: "error", message: `kimi spawn failed: ${err.message}` });
272
+ this.emit({ type: "turn_complete" });
273
+ }
274
+ resolve();
275
+ });
276
+ });
277
+ }
278
+
279
+ async interrupt(): Promise<void> {
280
+ // Kill the running turn's exec. Best-effort — the in-container `kimi` may
281
+ // finish the current tool, but no further output is emitted.
282
+ this.current?.kill("SIGKILL");
283
+ }
284
+
285
+ // `-p` mode auto-approves tools (the container is the sandbox), so no
286
+ // permission requests are ever emitted — nothing to resolve.
287
+ async resolvePermission(): Promise<void> {
288
+ /* no-op */
289
+ }
290
+
291
+ async close(): Promise<void> {
292
+ if (this.closed) return;
293
+ this.closed = true;
294
+ this.current?.kill("SIGKILL");
295
+ this.current = null;
296
+ this.emit({ type: "exit", code: 0 });
297
+ this.handlers.clear();
298
+ }
299
+ }
300
+
301
+ // Register the Kimi Code adapter at module load (ADR-021).
302
+ register({
303
+ kind: "kimi",
304
+ label: "Kimi Code",
305
+ supportedModels: () => [...KIMI_MODELS],
306
+ defaultModel: KIMI_DEFAULT_MODEL,
307
+ supportedEfforts: () => [...KIMI_EFFORTS],
308
+ // Gated on the operator's Kimi Code subscription credential (copied into
309
+ // containers at task-up). No login → the engine isn't advertised.
310
+ available: () => existsSync(kimiCredPath()),
311
+ create: async ({ taskId, agent, containerName, systemPreamble, agentEnv }) =>
312
+ new KimiSession({ taskId, agent, containerName, systemPreamble, agentEnv }),
313
+ });
@@ -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,22 @@ 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
- async function hashBuildContext(): Promise<string | null> {
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(): { kimi: boolean; grok: boolean } {
80
+ const home = process.env.UAI_OWNER_HOME?.trim() || homedir();
81
+ return {
82
+ kimi: existsSync(join(home, ".kimi-code", "credentials", "kimi-code.json")),
83
+ grok: existsSync(join(home, ".grok", "auth.json")),
84
+ };
85
+ }
86
+
87
+ async function hashBuildContext(extra = ""): Promise<string | null> {
71
88
  try {
72
89
  const root = standardImageDir();
73
90
  const files: string[] = [];
@@ -87,6 +104,10 @@ async function hashBuildContext(): Promise<string | null> {
87
104
  hash.update(await readFile(join(root, rel)));
88
105
  hash.update("\0");
89
106
  }
107
+ // Build args (which optional engines are installed) are part of the image
108
+ // identity — a config change must invalidate the label so it rebuilds.
109
+ hash.update(extra);
110
+ hash.update("\0");
90
111
  return hash.digest("hex").slice(0, 32);
91
112
  } catch {
92
113
  return null;
@@ -304,7 +325,18 @@ export async function ensureStandardImage(): Promise<void> {
304
325
  // landed). The build context is content-hashed into an image label;
305
326
  // a mismatch triggers a rebuild (layer cache keeps it cheap).
306
327
  let imageReady = false;
307
- const contextHash = await hashBuildContext();
328
+ // Install only the optional engines the operator has configured; the flags
329
+ // are build args AND part of the content hash (so a new login rebuilds).
330
+ const engines = configuredOptionalEngines();
331
+ const engineArgs = [
332
+ "--build-arg",
333
+ `INSTALL_KIMI=${engines.kimi ? 1 : 0}`,
334
+ "--build-arg",
335
+ `INSTALL_GROK=${engines.grok ? 1 : 0}`,
336
+ ];
337
+ const contextHash = await hashBuildContext(
338
+ `kimi=${engines.kimi ? 1 : 0};grok=${engines.grok ? 1 : 0}`,
339
+ );
308
340
  const inspect = await run("docker", [
309
341
  "image",
310
342
  "inspect",
@@ -338,6 +370,7 @@ export async function ensureStandardImage(): Promise<void> {
338
370
  "build",
339
371
  "-t",
340
372
  STANDARD_IMAGE_TAG,
373
+ ...engineArgs,
341
374
  ...(contextHash !== null
342
375
  ? ["--label", `${CONTEXT_HASH_LABEL}=${contextHash}`]
343
376
  : []),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runuai/host",
3
- "version": "0.8.2",
3
+ "version": "0.8.4",
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>",
@@ -469,6 +469,44 @@ done
469
469
  docker exec -u root "$app_container" \
470
470
  chown -R node:node /home/node/.codex >/dev/null 2>&1 || true
471
471
 
472
+ # Copy Kimi Code subscription/config into a task-private /home/node/.kimi-code.
473
+ # The Linux `kimi` binary is baked into the image; here we copy ONLY the
474
+ # arch-independent config + credential (never the host's macOS bin/, nor live
475
+ # session state) so the container's kimi runs on the operator's subscription.
476
+ docker exec -u root "$app_container" \
477
+ mkdir -p /home/node/.kimi-code/credentials >/dev/null 2>&1 || true
478
+ if [ -e "$UAI_OWNER_HOME/.kimi-code/config.toml" ]; then
479
+ docker cp "$UAI_OWNER_HOME/.kimi-code/config.toml" \
480
+ "$app_container":/home/node/.kimi-code/config.toml >/dev/null 2>&1 \
481
+ || log "warning: docker cp of .kimi-code/config.toml failed; kimi may need re-login"
482
+ fi
483
+ if [ -e "$UAI_OWNER_HOME/.kimi-code/device_id" ]; then
484
+ docker cp "$UAI_OWNER_HOME/.kimi-code/device_id" \
485
+ "$app_container":/home/node/.kimi-code/device_id >/dev/null 2>&1 || true
486
+ fi
487
+ if [ -e "$UAI_OWNER_HOME/.kimi-code/credentials/kimi-code.json" ]; then
488
+ docker cp "$UAI_OWNER_HOME/.kimi-code/credentials/kimi-code.json" \
489
+ "$app_container":/home/node/.kimi-code/credentials/kimi-code.json >/dev/null 2>&1 \
490
+ || log "warning: docker cp of .kimi-code credential failed; kimi may need re-login"
491
+ fi
492
+ docker exec -u root "$app_container" \
493
+ chown -R node:node /home/node/.kimi-code >/dev/null 2>&1 || true
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
+
472
510
  # Copy the same resolved SSH identity (task creator's per-user key when present,
473
511
  # else the operator identity — see above) into the container, so the agent signs
474
512
  # + pushes with the key whose .pub the user registered on GitHub. ADR-027 drops