@runuai/host 0.8.1 → 0.8.3
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 +10 -0
- package/lib/agents/factory.ts +1 -0
- package/lib/agents/kimi.ts +313 -0
- package/lib/codex-auth.ts +155 -0
- package/package.json +1 -1
- package/scripts/agent/task-up.sh +23 -0
- package/src/main.ts +7 -0
|
@@ -219,6 +219,16 @@ RUN bash -lc '\
|
|
|
219
219
|
asdf reshim nodejs; \
|
|
220
220
|
'
|
|
221
221
|
|
|
222
|
+
# Kimi Code CLI (Moonshot) — single-binary install to /home/node/.kimi-code/bin.
|
|
223
|
+
# The adapter invokes it by absolute path (docker exec ignores login-shell PATH),
|
|
224
|
+
# and task-up copies the operator subscription creds in. Non-fatal: a Kimi CDN
|
|
225
|
+
# hiccup must not break the image for Claude/Codex (the kimi engine just won't
|
|
226
|
+
# be runnable until rebuilt).
|
|
227
|
+
RUN bash -lc '\
|
|
228
|
+
curl -fsSL https://code.kimi.com/kimi-code/install.sh | bash \
|
|
229
|
+
|| echo "[warn] kimi-code install failed — kimi engine unavailable in this image"; \
|
|
230
|
+
'
|
|
231
|
+
|
|
222
232
|
ENV PATH=/home/node/.local/bin:$PATH
|
|
223
233
|
|
|
224
234
|
# ---------------------------------------------------------------------------
|
package/lib/agents/factory.ts
CHANGED
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Codex credential re-injection — the symmetric partner to the GitHub reinject
|
|
3
|
+
* in {@link ./github-tokens} (ADR-027/033 → host 0.8.1).
|
|
4
|
+
*
|
|
5
|
+
* The host owner's `~/.codex` is docker-cp'd into each task container ONCE, at
|
|
6
|
+
* task-up (`scripts/agent/task-up.sh`). When the owner re-logs into Codex —
|
|
7
|
+
* the refresh token gets revoked (a login elsewhere) and `codex login` rewrites
|
|
8
|
+
* `~/.codex/auth.json` — already-running containers keep their stale copy, so a
|
|
9
|
+
* re-login otherwise only helps NEW tasks. This re-copies the fresh `~/.codex`
|
|
10
|
+
* into the running task containers so live tasks self-heal.
|
|
11
|
+
*
|
|
12
|
+
* Two triggers, covering both re-login paths:
|
|
13
|
+
* - {@link watchCodexAuth} — fs.watch on `~/.codex`, for `codex login` run
|
|
14
|
+
* from a terminal while the host stays up.
|
|
15
|
+
* - {@link reinjectCodexRunningTasks} at host start — for the desktop
|
|
16
|
+
* "Connect Codex" flow, which writes `~/.codex` then restarts the host
|
|
17
|
+
* (durable sessions reattach to containers that still hold the old creds).
|
|
18
|
+
*
|
|
19
|
+
* Codex auth is host-wide (the operator's single `~/.codex`, not per-user), so
|
|
20
|
+
* this targets ALL running tasks — matching task-up, which copies it into every
|
|
21
|
+
* container regardless of roster.
|
|
22
|
+
*/
|
|
23
|
+
import { existsSync, watch } from "node:fs";
|
|
24
|
+
import { homedir } from "node:os";
|
|
25
|
+
import { join } from "node:path";
|
|
26
|
+
|
|
27
|
+
import { isNull } from "drizzle-orm";
|
|
28
|
+
|
|
29
|
+
import { getDb, schema } from "./db";
|
|
30
|
+
import { dockerCli } from "./docker-exec";
|
|
31
|
+
|
|
32
|
+
/** The exact set task-up.sh copies into `/home/node/.codex`. */
|
|
33
|
+
const CODEX_ITEMS = [
|
|
34
|
+
"auth.json",
|
|
35
|
+
"config.toml",
|
|
36
|
+
"AGENTS.md",
|
|
37
|
+
"version.json",
|
|
38
|
+
"installation_id",
|
|
39
|
+
"rules",
|
|
40
|
+
] as const;
|
|
41
|
+
const EXEC_TIMEOUT_MS = 15_000;
|
|
42
|
+
|
|
43
|
+
/** Injectable seams (defaults hit the real DB / docker / fs) so the copy logic
|
|
44
|
+
* is testable without either. */
|
|
45
|
+
export interface CodexDeps {
|
|
46
|
+
exec?: (args: string[]) => Promise<{ status: number | null; stderr: string }>;
|
|
47
|
+
runningContainers?: () => string[];
|
|
48
|
+
fileExists?: (path: string) => boolean;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function ownerCodexDir(): string {
|
|
52
|
+
return join(process.env.UAI_OWNER_HOME?.trim() || homedir(), ".codex");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const defaultExec: NonNullable<CodexDeps["exec"]> = async (args) => {
|
|
56
|
+
const res = await dockerCli(args, { timeoutMs: EXEC_TIMEOUT_MS });
|
|
57
|
+
return { status: res.status, stderr: res.stderr };
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
function defaultRunningContainers(): string[] {
|
|
61
|
+
return getDb()
|
|
62
|
+
.select({ taskId: schema.hostTasks.taskId })
|
|
63
|
+
.from(schema.hostTasks)
|
|
64
|
+
.where(isNull(schema.hostTasks.endedAt))
|
|
65
|
+
.all()
|
|
66
|
+
.map((r) => `task-${r.taskId}-app-1`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Re-copy `~/.codex/<items>` into one container — mirrors task-up.sh exactly:
|
|
71
|
+
* ensure the dir (root), cp each item that exists, chown back to node. Best
|
|
72
|
+
* effort; a stopped/removed container makes the exec fail and is caught by the
|
|
73
|
+
* caller.
|
|
74
|
+
*/
|
|
75
|
+
async function copyCodexInto(container: string, deps: CodexDeps): Promise<void> {
|
|
76
|
+
const exec = deps.exec ?? defaultExec;
|
|
77
|
+
const exists = deps.fileExists ?? existsSync;
|
|
78
|
+
const dir = ownerCodexDir();
|
|
79
|
+
await exec(["exec", "-u", "root", container, "mkdir", "-p", "/home/node/.codex"]);
|
|
80
|
+
for (const item of CODEX_ITEMS) {
|
|
81
|
+
const src = join(dir, item);
|
|
82
|
+
if (!exists(src)) continue;
|
|
83
|
+
await exec(["cp", src, `${container}:/home/node/.codex/`]);
|
|
84
|
+
}
|
|
85
|
+
await exec([
|
|
86
|
+
"exec",
|
|
87
|
+
"-u",
|
|
88
|
+
"root",
|
|
89
|
+
container,
|
|
90
|
+
"chown",
|
|
91
|
+
"-R",
|
|
92
|
+
"node:node",
|
|
93
|
+
"/home/node/.codex",
|
|
94
|
+
]);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Re-copy the freshly (re)logged-in `~/.codex` into every running task
|
|
99
|
+
* container on this host. No-op when there is no `~/.codex/auth.json` (nothing
|
|
100
|
+
* to inject) or no running tasks. Best-effort per container.
|
|
101
|
+
*/
|
|
102
|
+
export async function reinjectCodexRunningTasks(deps: CodexDeps = {}): Promise<void> {
|
|
103
|
+
const exists = deps.fileExists ?? existsSync;
|
|
104
|
+
if (!exists(join(ownerCodexDir(), "auth.json"))) return;
|
|
105
|
+
const containers = (deps.runningContainers ?? defaultRunningContainers)();
|
|
106
|
+
if (containers.length === 0) return;
|
|
107
|
+
console.log(`[codex] re-copying ~/.codex into ${containers.length} running task(s)`);
|
|
108
|
+
for (const container of containers) {
|
|
109
|
+
try {
|
|
110
|
+
await copyCodexInto(container, deps);
|
|
111
|
+
} catch (err) {
|
|
112
|
+
console.warn(
|
|
113
|
+
`[codex] reinject into ${container} failed: ${err instanceof Error ? err.message : err}`,
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
let watcher: ReturnType<typeof watch> | null = null;
|
|
120
|
+
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Watch `~/.codex` for `auth.json` changes (a re-login) and re-inject into
|
|
124
|
+
* running tasks, debounced (a login writes several files). Idempotent; a
|
|
125
|
+
* missing `~/.codex` dir is a no-op — the host-start reinject already handles
|
|
126
|
+
* a first login that flips Codex available. UAI_CODEX_REINJECT=0 disables it.
|
|
127
|
+
*/
|
|
128
|
+
export function watchCodexAuth(): void {
|
|
129
|
+
if (watcher || process.env.UAI_CODEX_REINJECT === "0") return;
|
|
130
|
+
const dir = ownerCodexDir();
|
|
131
|
+
if (!existsSync(dir)) return;
|
|
132
|
+
try {
|
|
133
|
+
watcher = watch(dir, (_event, filename) => {
|
|
134
|
+
// filename is null on some platforms — then we can't tell, so proceed.
|
|
135
|
+
if (filename && filename !== "auth.json") return;
|
|
136
|
+
if (debounceTimer) clearTimeout(debounceTimer);
|
|
137
|
+
debounceTimer = setTimeout(() => void reinjectCodexRunningTasks(), 2_000);
|
|
138
|
+
debounceTimer.unref?.();
|
|
139
|
+
});
|
|
140
|
+
} catch (err) {
|
|
141
|
+
console.warn(
|
|
142
|
+
`[codex] could not watch ${dir}: ${err instanceof Error ? err.message : err}`,
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Test/teardown hook. */
|
|
148
|
+
export function stopWatchingCodexAuth(): void {
|
|
149
|
+
watcher?.close();
|
|
150
|
+
watcher = null;
|
|
151
|
+
if (debounceTimer) {
|
|
152
|
+
clearTimeout(debounceTimer);
|
|
153
|
+
debounceTimer = null;
|
|
154
|
+
}
|
|
155
|
+
}
|
package/package.json
CHANGED
package/scripts/agent/task-up.sh
CHANGED
|
@@ -469,6 +469,29 @@ 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
|
+
|
|
472
495
|
# Copy the same resolved SSH identity (task creator's per-user key when present,
|
|
473
496
|
# else the operator identity — see above) into the container, so the agent signs
|
|
474
497
|
# + pushes with the key whose .pub the user registered on GitHub. ADR-027 drops
|
package/src/main.ts
CHANGED
|
@@ -32,6 +32,7 @@ import {
|
|
|
32
32
|
onGithubChange,
|
|
33
33
|
setAuthExpiredHandler,
|
|
34
34
|
} from "../lib/github-tokens";
|
|
35
|
+
import { reinjectCodexRunningTasks, watchCodexAuth } from "../lib/codex-auth";
|
|
35
36
|
import {
|
|
36
37
|
deleteKey as deleteSshKey,
|
|
37
38
|
ensureKeyForUser as ensureSshKeyForUser,
|
|
@@ -154,6 +155,12 @@ setAuthExpiredHandler((taskId, _userId, reason) => {
|
|
|
154
155
|
// Best-effort: build the standard image + asdf volume if missing. Logs and
|
|
155
156
|
// continues on failure (e.g. docker unavailable) so the host still boots.
|
|
156
157
|
void ensureStandardImage();
|
|
158
|
+
// Codex creds are docker-cp'd into containers at task-up. A re-login (revoked
|
|
159
|
+
// token → `codex login`) otherwise reaches only NEW tasks — re-copy into
|
|
160
|
+
// running tasks on start (covers the desktop "Connect Codex", which restarts
|
|
161
|
+
// the host) and watch ~/.codex for future logins (a terminal `codex login`).
|
|
162
|
+
void reinjectCodexRunningTasks();
|
|
163
|
+
watchCodexAuth();
|
|
157
164
|
connect();
|
|
158
165
|
// Local browser UI (ADR-028) — same single process, alongside the WSS client.
|
|
159
166
|
// Best-effort: a UI bind failure must not take the host service down.
|