@runuai/host 0.9.14 → 0.9.43
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 +33 -2
- 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 +3051 -200
- package/lib/preview-sidecar.ts +68 -14
- 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 +956 -0
- package/lib/task-environment/index.ts +364 -0
- package/lib/task-environment/legacy-adoption.ts +459 -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 +1405 -107
- 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 +766 -42
- 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
|
@@ -0,0 +1,1952 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generation-scoped remote engine login (ADR-100).
|
|
3
|
+
*
|
|
4
|
+
* OAuth happens in a short-lived standard-image container, never in a task
|
|
5
|
+
* container. The cloud sees only a validated HTTPS authorization URL and
|
|
6
|
+
* secret-blind progress. One-time codes/callback queries are accepted once,
|
|
7
|
+
* consumed locally, and are never copied into an event, error, or log line.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { spawn } from "node:child_process";
|
|
11
|
+
import { randomBytes } from "node:crypto";
|
|
12
|
+
import { constants, existsSync } from "node:fs";
|
|
13
|
+
import {
|
|
14
|
+
chmod,
|
|
15
|
+
lstat,
|
|
16
|
+
mkdir,
|
|
17
|
+
mkdtemp,
|
|
18
|
+
open,
|
|
19
|
+
readdir,
|
|
20
|
+
realpath,
|
|
21
|
+
rename,
|
|
22
|
+
rm,
|
|
23
|
+
unlink,
|
|
24
|
+
} from "node:fs/promises";
|
|
25
|
+
import { homedir, tmpdir } from "node:os";
|
|
26
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
27
|
+
|
|
28
|
+
import {
|
|
29
|
+
isHostOpId,
|
|
30
|
+
MAX_ENGINE_LOGIN_QUERY_CHARS,
|
|
31
|
+
MAX_ENGINE_LOGIN_TEXT_CHARS,
|
|
32
|
+
MAX_ENGINE_LOGIN_URL_CHARS,
|
|
33
|
+
type EngineLoginCapability,
|
|
34
|
+
type EngineLoginErrorCode,
|
|
35
|
+
type EngineLoginEventFrame,
|
|
36
|
+
type EngineLoginKind,
|
|
37
|
+
} from "../src/protocol";
|
|
38
|
+
import { reinjectCodexRunningTasks } from "./codex-auth";
|
|
39
|
+
import {
|
|
40
|
+
appleContainerRuntimeBinding,
|
|
41
|
+
pinnedContainerRuntimeProvider,
|
|
42
|
+
} from "./container-runtime";
|
|
43
|
+
import { dockerCli } from "./docker-exec";
|
|
44
|
+
import { env } from "./env";
|
|
45
|
+
import {
|
|
46
|
+
serializeEnvFileMutation,
|
|
47
|
+
writeEnvValuesAtomic,
|
|
48
|
+
} from "./env-file";
|
|
49
|
+
import { ASDF_DATA_VOLUME, STANDARD_IMAGE_TAG } from "./standard-image";
|
|
50
|
+
|
|
51
|
+
export const ENGINE_LOGIN_TIMEOUT_MS = 10 * 60_000;
|
|
52
|
+
// Global serialization keeps a failed per-operation stale-resource sweep from
|
|
53
|
+
// ever deleting another live login's labeled container or marked temp dir.
|
|
54
|
+
export const MAX_CONCURRENT_ENGINE_LOGINS = 1;
|
|
55
|
+
|
|
56
|
+
const LOGIN_MOUNT = "/run/uai-engine-login";
|
|
57
|
+
const LOGIN_HOME = `${LOGIN_MOUNT}/home`;
|
|
58
|
+
const CODEX_HOME = `${LOGIN_MOUNT}/codex`;
|
|
59
|
+
const MAX_OUTPUT_TAIL_BYTES = 64 * 1_024;
|
|
60
|
+
const MAX_CODEX_AUTH_BYTES = 1024 * 1024;
|
|
61
|
+
const DOCKER_CONTROL_TIMEOUT_MS = 15_000;
|
|
62
|
+
const DOCKER_CLEANUP_TIMEOUT_MS = 5_000;
|
|
63
|
+
const DOCKER_CLEANUP_ATTEMPTS = 3;
|
|
64
|
+
const DOCKER_RUN_STOP_GRACE_MS = 1_000;
|
|
65
|
+
const MAX_STALE_LOGIN_CONTAINERS = 64;
|
|
66
|
+
const MAX_STALE_LOGIN_TEMP_DIRS = 64;
|
|
67
|
+
const ENGINE_LOGIN_TEMP_DIR_PATTERN = /^uai-engine-login-[A-Za-z0-9]{6}$/;
|
|
68
|
+
const ENGINE_LOGIN_TEMP_MARKER = ".uai-engine-login-owner";
|
|
69
|
+
const ENGINE_LOGIN_TEMP_MARKER_VERSION = "v1";
|
|
70
|
+
export const ENGINE_LOGIN_CONTAINER_LABEL = "com.runuai.engine-login";
|
|
71
|
+
export const ENGINE_LOGIN_CONTAINER_LABEL_VALUE = "v1";
|
|
72
|
+
|
|
73
|
+
type EmitEngineLoginEvent = (event: EngineLoginEventFrame) => void;
|
|
74
|
+
|
|
75
|
+
export interface EngineLoginProcessResult {
|
|
76
|
+
readonly code: number | null;
|
|
77
|
+
readonly spawnFailed?: boolean;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** A running `docker run --rm` login container. */
|
|
81
|
+
export interface EngineLoginProcess {
|
|
82
|
+
readonly containerName: string;
|
|
83
|
+
readonly completion: Promise<EngineLoginProcessResult>;
|
|
84
|
+
writeInput(value: string): void;
|
|
85
|
+
stop(): Promise<void>;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface EngineLoginContainerRequest {
|
|
89
|
+
readonly engine: EngineLoginKind;
|
|
90
|
+
readonly containerName: string;
|
|
91
|
+
readonly tempDir: string;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface EngineLoginDockerResult {
|
|
95
|
+
readonly status: number | null;
|
|
96
|
+
readonly stdout: string;
|
|
97
|
+
readonly stderr: string;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export type EngineLoginDockerExec = (
|
|
101
|
+
args: string[],
|
|
102
|
+
options: {
|
|
103
|
+
input?: string;
|
|
104
|
+
timeoutMs: number;
|
|
105
|
+
maxOutputBytes: number;
|
|
106
|
+
},
|
|
107
|
+
) => Promise<EngineLoginDockerResult>;
|
|
108
|
+
|
|
109
|
+
export interface EngineLoginTimers {
|
|
110
|
+
setTimeout(callback: () => void, delayMs: number): unknown;
|
|
111
|
+
clearTimeout(timer: unknown): void;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export interface EngineLoginSeams {
|
|
115
|
+
createTempDir(): Promise<string>;
|
|
116
|
+
removeTempDir(path: string): Promise<void>;
|
|
117
|
+
startContainer(
|
|
118
|
+
request: EngineLoginContainerRequest,
|
|
119
|
+
onOutput: (chunk: string) => void,
|
|
120
|
+
): Promise<EngineLoginProcess>;
|
|
121
|
+
replayCodexCallback(
|
|
122
|
+
containerName: string,
|
|
123
|
+
callbackUrl: string,
|
|
124
|
+
): Promise<void>;
|
|
125
|
+
reconcileStaleContainers(): Promise<void>;
|
|
126
|
+
persistClaudeToken(token: string): Promise<void>;
|
|
127
|
+
persistCodexAuth(sourceFile: string): Promise<void>;
|
|
128
|
+
refreshEngineAccountAgents(engine: "claude"): Promise<void>;
|
|
129
|
+
reinjectCodexRunningTasks(): Promise<void>;
|
|
130
|
+
ownerHome(): string;
|
|
131
|
+
procEnv: NodeJS.ProcessEnv;
|
|
132
|
+
fileExists(path: string): boolean;
|
|
133
|
+
randomHex(bytes: number): string;
|
|
134
|
+
timers: EngineLoginTimers;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export interface EngineLoginManagerOptions {
|
|
138
|
+
/** The running orchestrator owns this operation; main.ts supplies it. */
|
|
139
|
+
refreshEngineAccountAgents(engine: "claude"): Promise<void>;
|
|
140
|
+
/**
|
|
141
|
+
* Exact host directory used for short-lived bind inputs. main.ts supplies
|
|
142
|
+
* a private leaf under UAI_HOME; ambient Docker mounts it directly.
|
|
143
|
+
*/
|
|
144
|
+
tempRoot?: string;
|
|
145
|
+
/** Defer Docker inventory until the first start when no runtime is selected. */
|
|
146
|
+
reconcileOnCreate?: boolean;
|
|
147
|
+
seams?: Partial<EngineLoginSeams>;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
type OperationStage = "starting" | "awaiting_input" | "awaiting_callback";
|
|
151
|
+
|
|
152
|
+
interface CodexCallbackTarget {
|
|
153
|
+
readonly origin: string;
|
|
154
|
+
readonly pathname: string;
|
|
155
|
+
readonly port: string;
|
|
156
|
+
readonly hostname: string;
|
|
157
|
+
readonly state: string;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
interface LoginOperation {
|
|
161
|
+
readonly opId: string;
|
|
162
|
+
readonly engine: EngineLoginKind;
|
|
163
|
+
readonly emit: EmitEngineLoginEvent;
|
|
164
|
+
active: boolean;
|
|
165
|
+
finishing: boolean;
|
|
166
|
+
stage: OperationStage;
|
|
167
|
+
timer: unknown | null;
|
|
168
|
+
process: EngineLoginProcess | null;
|
|
169
|
+
tempDir: string | null;
|
|
170
|
+
launchPromise: Promise<void> | null;
|
|
171
|
+
callbackPromise: Promise<boolean> | null;
|
|
172
|
+
finishPromise: Promise<void> | null;
|
|
173
|
+
cleanupPromise: Promise<void> | null;
|
|
174
|
+
settlementPromise: Promise<void> | null;
|
|
175
|
+
suppressTerminalEvent: boolean;
|
|
176
|
+
outputTail: string;
|
|
177
|
+
inputUsed: boolean;
|
|
178
|
+
callbackUsed: boolean;
|
|
179
|
+
pendingInput: string | null;
|
|
180
|
+
callbackTarget: CodexCallbackTarget | null;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const defaultTimers: EngineLoginTimers = {
|
|
184
|
+
setTimeout: (callback, delayMs) => setTimeout(callback, delayMs),
|
|
185
|
+
clearTimeout: (timer) => clearTimeout(timer as ReturnType<typeof setTimeout>),
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
function unrefTimer(timer: unknown): void {
|
|
189
|
+
if (
|
|
190
|
+
typeof timer === "object" &&
|
|
191
|
+
timer !== null &&
|
|
192
|
+
"unref" in timer &&
|
|
193
|
+
typeof (timer as { unref?: unknown }).unref === "function"
|
|
194
|
+
) {
|
|
195
|
+
(timer as { unref(): void }).unref();
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function ownerHome(): string {
|
|
200
|
+
return process.env.UAI_OWNER_HOME?.trim() || homedir();
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function envLocalPath(): string {
|
|
204
|
+
return join(env.uaiHome, ".env.local");
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export async function persistClaudeTokenAtomic(
|
|
208
|
+
token: string,
|
|
209
|
+
options: {
|
|
210
|
+
envFile?: string;
|
|
211
|
+
procEnv?: NodeJS.ProcessEnv;
|
|
212
|
+
} = {},
|
|
213
|
+
): Promise<void> {
|
|
214
|
+
if (extractClaudeOAuthToken(token) !== token) {
|
|
215
|
+
throw new Error("invalid Claude OAuth token");
|
|
216
|
+
}
|
|
217
|
+
const file = options.envFile ?? envLocalPath();
|
|
218
|
+
await serializeEnvFileMutation(file, () => {
|
|
219
|
+
writeEnvValuesAtomic(file, { CLAUDE_CODE_OAUTH_TOKEN: token });
|
|
220
|
+
});
|
|
221
|
+
// Agent availability and task env injection read the live process env.
|
|
222
|
+
(options.procEnv ?? process.env).CLAUDE_CODE_OAUTH_TOKEN = token;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export async function persistCodexAuthAtomic(
|
|
226
|
+
sourceFile: string,
|
|
227
|
+
options: { ownerHome?: string } = {},
|
|
228
|
+
): Promise<void> {
|
|
229
|
+
const body = await readBoundedRegularFile(sourceFile, MAX_CODEX_AUTH_BYTES);
|
|
230
|
+
let parsed: unknown;
|
|
231
|
+
try {
|
|
232
|
+
parsed = JSON.parse(body.toString("utf8"));
|
|
233
|
+
} catch {
|
|
234
|
+
throw new Error("Codex produced invalid auth data");
|
|
235
|
+
}
|
|
236
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
237
|
+
throw new Error("Codex produced invalid auth data");
|
|
238
|
+
}
|
|
239
|
+
await writeSensitiveFileAtomic(
|
|
240
|
+
join(options.ownerHome ?? ownerHome(), ".codex", "auth.json"),
|
|
241
|
+
body,
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function defaultSeams(options: EngineLoginManagerOptions): EngineLoginSeams {
|
|
246
|
+
const tempRoot = options.tempRoot;
|
|
247
|
+
return {
|
|
248
|
+
createTempDir: () => createEngineLoginTempDir(tempRoot),
|
|
249
|
+
removeTempDir: (path) => rm(path, { recursive: true, force: true }),
|
|
250
|
+
startContainer: startLoginContainer,
|
|
251
|
+
replayCodexCallback,
|
|
252
|
+
reconcileStaleContainers: () =>
|
|
253
|
+
reconcileStaleEngineLoginResources({ tempRoot }),
|
|
254
|
+
persistClaudeToken: (token) => persistClaudeTokenAtomic(token),
|
|
255
|
+
persistCodexAuth: (sourceFile) => persistCodexAuthAtomic(sourceFile),
|
|
256
|
+
refreshEngineAccountAgents: options.refreshEngineAccountAgents,
|
|
257
|
+
reinjectCodexRunningTasks: () => reinjectCodexRunningTasks(),
|
|
258
|
+
ownerHome,
|
|
259
|
+
procEnv: process.env,
|
|
260
|
+
fileExists: existsSync,
|
|
261
|
+
randomHex: (bytes) => randomBytes(bytes).toString("hex"),
|
|
262
|
+
timers: defaultTimers,
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function withDefaults(options: EngineLoginManagerOptions): EngineLoginSeams {
|
|
267
|
+
const defaults = defaultSeams(options);
|
|
268
|
+
return {
|
|
269
|
+
...defaults,
|
|
270
|
+
...options.seams,
|
|
271
|
+
timers: { ...defaults.timers, ...options.seams?.timers },
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* One manager belongs to one authenticated socket generation. Disconnect must
|
|
277
|
+
* call `stopAll()` before dropping the socket emitter.
|
|
278
|
+
*/
|
|
279
|
+
export class EngineLoginManager {
|
|
280
|
+
private readonly seams: EngineLoginSeams;
|
|
281
|
+
private readonly operations = new Map<string, LoginOperation>();
|
|
282
|
+
private readonly engineOperations = new Map<EngineLoginKind, LoginOperation>();
|
|
283
|
+
private reconciliation: Promise<boolean>;
|
|
284
|
+
|
|
285
|
+
constructor(options: EngineLoginManagerOptions) {
|
|
286
|
+
this.seams = withDefaults(options);
|
|
287
|
+
this.reconciliation =
|
|
288
|
+
options.reconcileOnCreate === false
|
|
289
|
+
? Promise.resolve(false)
|
|
290
|
+
: this.runReconciliation();
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** Secret-free current capability state. v1 deliberately claims no expiry. */
|
|
294
|
+
capabilities(): EngineLoginCapability[] {
|
|
295
|
+
const claudeConfigured = Boolean(
|
|
296
|
+
this.seams.procEnv.CLAUDE_CODE_OAUTH_TOKEN ||
|
|
297
|
+
this.seams.procEnv.ANTHROPIC_API_KEY ||
|
|
298
|
+
this.seams.procEnv.ANTHROPIC_AUTH_TOKEN,
|
|
299
|
+
);
|
|
300
|
+
const codexConfigured = this.seams.fileExists(
|
|
301
|
+
join(this.seams.ownerHome(), ".codex", "auth.json"),
|
|
302
|
+
);
|
|
303
|
+
return [
|
|
304
|
+
{
|
|
305
|
+
engine: "claude",
|
|
306
|
+
status: claudeConfigured ? "configured" : "missing",
|
|
307
|
+
loginSupported: true,
|
|
308
|
+
},
|
|
309
|
+
{
|
|
310
|
+
engine: "codex",
|
|
311
|
+
status: codexConfigured ? "configured" : "missing",
|
|
312
|
+
loginSupported: true,
|
|
313
|
+
},
|
|
314
|
+
];
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** Reserve and launch without waiting for a human-paced OAuth flow. */
|
|
318
|
+
start(
|
|
319
|
+
opId: string,
|
|
320
|
+
engine: EngineLoginKind,
|
|
321
|
+
emit: EmitEngineLoginEvent,
|
|
322
|
+
): boolean {
|
|
323
|
+
if (!isHostOpId(opId)) return false;
|
|
324
|
+
if (this.operations.has(opId)) {
|
|
325
|
+
safeEmit(emit, failedEvent(opId, engine, "unavailable", "Login operation already exists."));
|
|
326
|
+
return false;
|
|
327
|
+
}
|
|
328
|
+
if (this.engineOperations.has(engine)) {
|
|
329
|
+
safeEmit(
|
|
330
|
+
emit,
|
|
331
|
+
failedEvent(
|
|
332
|
+
opId,
|
|
333
|
+
engine,
|
|
334
|
+
"unavailable",
|
|
335
|
+
`A ${engineLabel(engine)} login is already in progress.`,
|
|
336
|
+
),
|
|
337
|
+
);
|
|
338
|
+
return false;
|
|
339
|
+
}
|
|
340
|
+
if (this.operations.size >= MAX_CONCURRENT_ENGINE_LOGINS) {
|
|
341
|
+
safeEmit(
|
|
342
|
+
emit,
|
|
343
|
+
failedEvent(opId, engine, "unavailable", "Too many login operations are active."),
|
|
344
|
+
);
|
|
345
|
+
return false;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const operation: LoginOperation = {
|
|
349
|
+
opId,
|
|
350
|
+
engine,
|
|
351
|
+
emit,
|
|
352
|
+
active: true,
|
|
353
|
+
finishing: false,
|
|
354
|
+
stage: "starting",
|
|
355
|
+
timer: null,
|
|
356
|
+
process: null,
|
|
357
|
+
tempDir: null,
|
|
358
|
+
launchPromise: null,
|
|
359
|
+
callbackPromise: null,
|
|
360
|
+
finishPromise: null,
|
|
361
|
+
cleanupPromise: null,
|
|
362
|
+
settlementPromise: null,
|
|
363
|
+
suppressTerminalEvent: false,
|
|
364
|
+
outputTail: "",
|
|
365
|
+
inputUsed: false,
|
|
366
|
+
callbackUsed: false,
|
|
367
|
+
pendingInput: null,
|
|
368
|
+
callbackTarget: null,
|
|
369
|
+
};
|
|
370
|
+
this.operations.set(opId, operation);
|
|
371
|
+
this.engineOperations.set(engine, operation);
|
|
372
|
+
operation.timer = this.seams.timers.setTimeout(() => {
|
|
373
|
+
void this.timeout(operation);
|
|
374
|
+
}, ENGINE_LOGIN_TIMEOUT_MS);
|
|
375
|
+
unrefTimer(operation.timer);
|
|
376
|
+
|
|
377
|
+
safeEmit(emit, {
|
|
378
|
+
kind: "engine.login.event",
|
|
379
|
+
opId,
|
|
380
|
+
engine,
|
|
381
|
+
phase: "starting",
|
|
382
|
+
message: `Starting ${engineLabel(engine)} login on this host.`,
|
|
383
|
+
});
|
|
384
|
+
operation.launchPromise = this.launch(operation);
|
|
385
|
+
return true;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** Consume Claude's one-time code exactly once. */
|
|
389
|
+
input(opId: string, text: string): boolean {
|
|
390
|
+
const operation = this.operations.get(opId);
|
|
391
|
+
if (
|
|
392
|
+
!operation ||
|
|
393
|
+
!this.isCurrent(operation) ||
|
|
394
|
+
operation.engine !== "claude" ||
|
|
395
|
+
operation.stage !== "awaiting_input" ||
|
|
396
|
+
operation.inputUsed ||
|
|
397
|
+
operation.finishing
|
|
398
|
+
) {
|
|
399
|
+
return false;
|
|
400
|
+
}
|
|
401
|
+
if (!isValidOneTimeInput(text)) {
|
|
402
|
+
void this.fail(
|
|
403
|
+
operation,
|
|
404
|
+
"invalid_input",
|
|
405
|
+
"The one-time authorization code was rejected.",
|
|
406
|
+
);
|
|
407
|
+
return false;
|
|
408
|
+
}
|
|
409
|
+
operation.inputUsed = true;
|
|
410
|
+
operation.outputTail = "";
|
|
411
|
+
const value = `${text}\n`;
|
|
412
|
+
if (!operation.process) {
|
|
413
|
+
operation.pendingInput = value;
|
|
414
|
+
return true;
|
|
415
|
+
}
|
|
416
|
+
try {
|
|
417
|
+
operation.process.writeInput(value);
|
|
418
|
+
return true;
|
|
419
|
+
} catch {
|
|
420
|
+
void this.fail(
|
|
421
|
+
operation,
|
|
422
|
+
"invalid_input",
|
|
423
|
+
"The login process could not accept the one-time code.",
|
|
424
|
+
);
|
|
425
|
+
return false;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/** Validate and replay a Codex callback only to this operation's listener. */
|
|
430
|
+
async callback(opId: string, value: string): Promise<boolean> {
|
|
431
|
+
const operation = this.operations.get(opId);
|
|
432
|
+
if (
|
|
433
|
+
!operation ||
|
|
434
|
+
!this.isCurrent(operation) ||
|
|
435
|
+
operation.engine !== "codex" ||
|
|
436
|
+
operation.stage !== "awaiting_callback" ||
|
|
437
|
+
operation.callbackUsed ||
|
|
438
|
+
operation.finishing ||
|
|
439
|
+
!operation.process ||
|
|
440
|
+
!operation.callbackTarget
|
|
441
|
+
) {
|
|
442
|
+
return false;
|
|
443
|
+
}
|
|
444
|
+
const query = validateCodexCallback(value, operation.callbackTarget);
|
|
445
|
+
if (!query) {
|
|
446
|
+
await this.fail(
|
|
447
|
+
operation,
|
|
448
|
+
"invalid_callback",
|
|
449
|
+
"The localhost callback was rejected.",
|
|
450
|
+
);
|
|
451
|
+
return false;
|
|
452
|
+
}
|
|
453
|
+
operation.callbackUsed = true;
|
|
454
|
+
const callbackUrl = callbackReplayUrl(operation.callbackTarget, query);
|
|
455
|
+
const replay = (async () => {
|
|
456
|
+
try {
|
|
457
|
+
await this.seams.replayCodexCallback(
|
|
458
|
+
operation.process!.containerName,
|
|
459
|
+
callbackUrl,
|
|
460
|
+
);
|
|
461
|
+
} catch {
|
|
462
|
+
if (this.isCurrent(operation)) {
|
|
463
|
+
await this.fail(
|
|
464
|
+
operation,
|
|
465
|
+
"invalid_callback",
|
|
466
|
+
"The localhost callback could not be delivered.",
|
|
467
|
+
);
|
|
468
|
+
}
|
|
469
|
+
return false;
|
|
470
|
+
}
|
|
471
|
+
return this.isCurrent(operation) || operation.finishing;
|
|
472
|
+
})();
|
|
473
|
+
operation.callbackPromise = replay;
|
|
474
|
+
try {
|
|
475
|
+
return await replay;
|
|
476
|
+
} finally {
|
|
477
|
+
if (operation.callbackPromise === replay) {
|
|
478
|
+
operation.callbackPromise = null;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/** Explicit operator cancellation emits one terminal event. */
|
|
484
|
+
async stop(opId: string): Promise<boolean> {
|
|
485
|
+
const operation = this.operations.get(opId);
|
|
486
|
+
if (!operation || !this.isCurrent(operation)) return false;
|
|
487
|
+
if (operation.finishPromise) {
|
|
488
|
+
// Credential commit is the irreversible boundary. Once it starts, join
|
|
489
|
+
// the truthful success/failure outcome instead of claiming cancellation.
|
|
490
|
+
await this.ensureSettlement(operation);
|
|
491
|
+
return true;
|
|
492
|
+
}
|
|
493
|
+
this.detach(operation);
|
|
494
|
+
safeEmit(operation.emit, {
|
|
495
|
+
kind: "engine.login.event",
|
|
496
|
+
opId: operation.opId,
|
|
497
|
+
engine: operation.engine,
|
|
498
|
+
phase: "cancelled",
|
|
499
|
+
message: "Login cancelled.",
|
|
500
|
+
});
|
|
501
|
+
await this.ensureSettlement(operation);
|
|
502
|
+
return true;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* Generation teardown. Detach every operation and secret before awaiting
|
|
507
|
+
* Docker/fs cleanup, so no late child callback can touch a dead socket.
|
|
508
|
+
*/
|
|
509
|
+
async stopAll(): Promise<void> {
|
|
510
|
+
const operations = [...this.operations.values()];
|
|
511
|
+
for (const operation of operations) {
|
|
512
|
+
operation.suppressTerminalEvent = true;
|
|
513
|
+
if (!operation.finishPromise) this.detach(operation);
|
|
514
|
+
else this.ensureSettlement(operation);
|
|
515
|
+
}
|
|
516
|
+
await Promise.all(
|
|
517
|
+
operations.map((operation) => this.ensureSettlement(operation)),
|
|
518
|
+
);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
activeCount(): number {
|
|
522
|
+
let active = 0;
|
|
523
|
+
for (const operation of this.operations.values()) {
|
|
524
|
+
if (operation.active) active += 1;
|
|
525
|
+
}
|
|
526
|
+
return active;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
private async launch(operation: LoginOperation): Promise<void> {
|
|
530
|
+
let tempDir: string | null = null;
|
|
531
|
+
let processHandle: EngineLoginProcess | null = null;
|
|
532
|
+
try {
|
|
533
|
+
if (!(await this.reconciliation)) {
|
|
534
|
+
// A daemon blip during the eager startup sweep gets one new bounded
|
|
535
|
+
// reconciliation on the next attempted login; never add an orphan on
|
|
536
|
+
// top of state whose ownership could not be established.
|
|
537
|
+
this.reconciliation = this.runReconciliation();
|
|
538
|
+
if (!(await this.reconciliation)) {
|
|
539
|
+
throw new Error("stale login containers could not be reconciled");
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
if (!this.isCurrent(operation)) return;
|
|
543
|
+
tempDir = await this.seams.createTempDir();
|
|
544
|
+
if (!this.isCurrent(operation)) {
|
|
545
|
+
await this.removeDetachedTemp(tempDir);
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
operation.tempDir = tempDir;
|
|
549
|
+
const containerName = `uai-login-${operation.engine}-${this.seams.randomHex(8)}`;
|
|
550
|
+
processHandle = await this.seams.startContainer(
|
|
551
|
+
{ engine: operation.engine, containerName, tempDir },
|
|
552
|
+
(chunk) => this.onOutput(operation, chunk),
|
|
553
|
+
);
|
|
554
|
+
if (!this.isCurrent(operation)) {
|
|
555
|
+
await this.stopDetachedProcess(processHandle);
|
|
556
|
+
if (operation.tempDir === tempDir) operation.tempDir = null;
|
|
557
|
+
await this.removeDetachedTemp(tempDir);
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
operation.process = processHandle;
|
|
561
|
+
if (operation.pendingInput !== null) {
|
|
562
|
+
const pending = operation.pendingInput;
|
|
563
|
+
operation.pendingInput = null;
|
|
564
|
+
try {
|
|
565
|
+
processHandle.writeInput(pending);
|
|
566
|
+
} catch {
|
|
567
|
+
await this.fail(
|
|
568
|
+
operation,
|
|
569
|
+
"invalid_input",
|
|
570
|
+
"The login process could not accept the one-time code.",
|
|
571
|
+
);
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
void processHandle.completion.then(
|
|
576
|
+
(result) => this.onProcessComplete(operation, result),
|
|
577
|
+
() =>
|
|
578
|
+
this.fail(
|
|
579
|
+
operation,
|
|
580
|
+
"start_failed",
|
|
581
|
+
"The login container stopped unexpectedly.",
|
|
582
|
+
),
|
|
583
|
+
);
|
|
584
|
+
} catch {
|
|
585
|
+
if (processHandle && !operation.process) {
|
|
586
|
+
await this.stopDetachedProcess(processHandle);
|
|
587
|
+
}
|
|
588
|
+
if (tempDir && !operation.tempDir) await this.removeDetachedTemp(tempDir);
|
|
589
|
+
if (this.isCurrent(operation)) {
|
|
590
|
+
await this.fail(
|
|
591
|
+
operation,
|
|
592
|
+
"start_failed",
|
|
593
|
+
"The login container could not be started.",
|
|
594
|
+
);
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
private onOutput(operation: LoginOperation, chunk: string): void {
|
|
600
|
+
if (!this.isCurrent(operation) || operation.finishing) return;
|
|
601
|
+
operation.outputTail = appendUtf8Tail(
|
|
602
|
+
operation.outputTail,
|
|
603
|
+
chunk,
|
|
604
|
+
MAX_OUTPUT_TAIL_BYTES,
|
|
605
|
+
);
|
|
606
|
+
|
|
607
|
+
if (operation.engine === "claude") {
|
|
608
|
+
// Before the one-time input is consumed, token-shaped output could only
|
|
609
|
+
// be an authorization URL/detail or hostile terminal content. This also
|
|
610
|
+
// prevents an input echo from becoming the credential boundary.
|
|
611
|
+
const token = operation.inputUsed
|
|
612
|
+
? extractClaudeOAuthToken(operation.outputTail)
|
|
613
|
+
: null;
|
|
614
|
+
if (token) {
|
|
615
|
+
operation.outputTail = "";
|
|
616
|
+
this.beginClaudeFinish(operation, token);
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
if (operation.stage === "starting") {
|
|
620
|
+
const authorizeUrl = findSafeHttpsUrl(operation.outputTail);
|
|
621
|
+
if (authorizeUrl) {
|
|
622
|
+
operation.stage = "awaiting_input";
|
|
623
|
+
safeEmit(operation.emit, authorizeEvent(operation, authorizeUrl));
|
|
624
|
+
safeEmit(operation.emit, {
|
|
625
|
+
kind: "engine.login.event",
|
|
626
|
+
opId: operation.opId,
|
|
627
|
+
engine: operation.engine,
|
|
628
|
+
phase: "awaiting_input",
|
|
629
|
+
message: "Paste the one-time authorization code.",
|
|
630
|
+
});
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
return;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
if (operation.stage === "starting") {
|
|
637
|
+
const authorization = findCodexAuthorization(operation.outputTail);
|
|
638
|
+
if (authorization) {
|
|
639
|
+
operation.callbackTarget = authorization.target;
|
|
640
|
+
operation.stage = "awaiting_callback";
|
|
641
|
+
safeEmit(
|
|
642
|
+
operation.emit,
|
|
643
|
+
authorizeEvent(operation, authorization.authorizeUrl),
|
|
644
|
+
);
|
|
645
|
+
safeEmit(operation.emit, {
|
|
646
|
+
kind: "engine.login.event",
|
|
647
|
+
opId: operation.opId,
|
|
648
|
+
engine: operation.engine,
|
|
649
|
+
phase: "awaiting_callback",
|
|
650
|
+
message: "Paste the failed localhost callback URL.",
|
|
651
|
+
});
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
private async onProcessComplete(
|
|
657
|
+
operation: LoginOperation,
|
|
658
|
+
result: EngineLoginProcessResult,
|
|
659
|
+
): Promise<void> {
|
|
660
|
+
if (!this.isCurrent(operation) || operation.finishing) return;
|
|
661
|
+
if (operation.engine === "codex" && result.code === 0) {
|
|
662
|
+
this.beginCodexFinish(operation);
|
|
663
|
+
if (operation.finishPromise) await operation.finishPromise;
|
|
664
|
+
return;
|
|
665
|
+
}
|
|
666
|
+
await this.fail(
|
|
667
|
+
operation,
|
|
668
|
+
result.spawnFailed ? "unavailable" : "start_failed",
|
|
669
|
+
result.spawnFailed
|
|
670
|
+
? "The login runtime is unavailable."
|
|
671
|
+
: `${engineLabel(operation.engine)} login did not complete.`,
|
|
672
|
+
);
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
private beginClaudeFinish(
|
|
676
|
+
operation: LoginOperation,
|
|
677
|
+
token: string,
|
|
678
|
+
): void {
|
|
679
|
+
if (!this.isCurrent(operation) || operation.finishing) return;
|
|
680
|
+
operation.finishing = true;
|
|
681
|
+
this.clearOperationTimer(operation);
|
|
682
|
+
operation.finishPromise = this.commitClaude(operation, token);
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
private async commitClaude(
|
|
686
|
+
operation: LoginOperation,
|
|
687
|
+
token: string,
|
|
688
|
+
): Promise<void> {
|
|
689
|
+
let terminal: EngineLoginEventFrame;
|
|
690
|
+
try {
|
|
691
|
+
await this.seams.persistClaudeToken(token);
|
|
692
|
+
} catch {
|
|
693
|
+
terminal = failedEvent(
|
|
694
|
+
operation.opId,
|
|
695
|
+
operation.engine,
|
|
696
|
+
"persist_failed",
|
|
697
|
+
"Claude authorized, but its credential could not be saved.",
|
|
698
|
+
);
|
|
699
|
+
await this.finishCommit(operation, terminal);
|
|
700
|
+
return;
|
|
701
|
+
}
|
|
702
|
+
// The credential is already durable. Refresh existing agents in the
|
|
703
|
+
// background: it may traverse many task containers, and must not retain
|
|
704
|
+
// this ephemeral login container/temp dir or block generation teardown.
|
|
705
|
+
runBestEffort(() => this.seams.refreshEngineAccountAgents("claude"));
|
|
706
|
+
terminal = {
|
|
707
|
+
kind: "engine.login.event",
|
|
708
|
+
opId: operation.opId,
|
|
709
|
+
engine: operation.engine,
|
|
710
|
+
phase: "succeeded",
|
|
711
|
+
message: "Claude connected.",
|
|
712
|
+
};
|
|
713
|
+
await this.finishCommit(operation, terminal);
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
private beginCodexFinish(operation: LoginOperation): void {
|
|
717
|
+
if (
|
|
718
|
+
!this.isCurrent(operation) ||
|
|
719
|
+
operation.finishing ||
|
|
720
|
+
!operation.tempDir
|
|
721
|
+
) {
|
|
722
|
+
return;
|
|
723
|
+
}
|
|
724
|
+
operation.finishing = true;
|
|
725
|
+
this.clearOperationTimer(operation);
|
|
726
|
+
operation.finishPromise = this.commitCodex(operation);
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
private async commitCodex(operation: LoginOperation): Promise<void> {
|
|
730
|
+
const tempDir = operation.tempDir;
|
|
731
|
+
if (!tempDir) return;
|
|
732
|
+
let terminal: EngineLoginEventFrame;
|
|
733
|
+
try {
|
|
734
|
+
await this.seams.persistCodexAuth(
|
|
735
|
+
join(tempDir, "codex", "auth.json"),
|
|
736
|
+
);
|
|
737
|
+
} catch {
|
|
738
|
+
terminal = failedEvent(
|
|
739
|
+
operation.opId,
|
|
740
|
+
operation.engine,
|
|
741
|
+
"persist_failed",
|
|
742
|
+
"Codex authorized, but its credential could not be saved.",
|
|
743
|
+
);
|
|
744
|
+
await this.finishCommit(operation, terminal);
|
|
745
|
+
return;
|
|
746
|
+
}
|
|
747
|
+
runBestEffort(() => this.seams.reinjectCodexRunningTasks());
|
|
748
|
+
terminal = {
|
|
749
|
+
kind: "engine.login.event",
|
|
750
|
+
opId: operation.opId,
|
|
751
|
+
engine: operation.engine,
|
|
752
|
+
phase: "succeeded",
|
|
753
|
+
message: "Codex connected.",
|
|
754
|
+
};
|
|
755
|
+
await this.finishCommit(operation, terminal);
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
private async finishCommit(
|
|
759
|
+
operation: LoginOperation,
|
|
760
|
+
terminal: EngineLoginEventFrame,
|
|
761
|
+
): Promise<void> {
|
|
762
|
+
if (this.isCurrent(operation)) this.detach(operation);
|
|
763
|
+
if (!operation.suppressTerminalEvent) safeEmit(operation.emit, terminal);
|
|
764
|
+
await this.cleanup(operation);
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
private async timeout(operation: LoginOperation): Promise<void> {
|
|
768
|
+
if (!this.isCurrent(operation)) return;
|
|
769
|
+
if (operation.finishPromise) {
|
|
770
|
+
await operation.finishPromise;
|
|
771
|
+
return;
|
|
772
|
+
}
|
|
773
|
+
this.detach(operation);
|
|
774
|
+
safeEmit(operation.emit, {
|
|
775
|
+
kind: "engine.login.event",
|
|
776
|
+
opId: operation.opId,
|
|
777
|
+
engine: operation.engine,
|
|
778
|
+
phase: "timed_out",
|
|
779
|
+
errorCode: "timeout",
|
|
780
|
+
message: "Login timed out.",
|
|
781
|
+
});
|
|
782
|
+
await this.ensureSettlement(operation);
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
private async fail(
|
|
786
|
+
operation: LoginOperation,
|
|
787
|
+
errorCode: EngineLoginErrorCode,
|
|
788
|
+
message: string,
|
|
789
|
+
): Promise<void> {
|
|
790
|
+
if (!this.isCurrent(operation)) return;
|
|
791
|
+
if (operation.finishPromise) {
|
|
792
|
+
await operation.finishPromise;
|
|
793
|
+
return;
|
|
794
|
+
}
|
|
795
|
+
this.detach(operation);
|
|
796
|
+
safeEmit(
|
|
797
|
+
operation.emit,
|
|
798
|
+
failedEvent(operation.opId, operation.engine, errorCode, message),
|
|
799
|
+
);
|
|
800
|
+
await this.cleanup(operation);
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
private detach(operation: LoginOperation): void {
|
|
804
|
+
operation.active = false;
|
|
805
|
+
operation.outputTail = "";
|
|
806
|
+
operation.pendingInput = null;
|
|
807
|
+
operation.callbackTarget = null;
|
|
808
|
+
this.clearOperationTimer(operation);
|
|
809
|
+
// Keep the exact operation reserved in both maps until cleanup settles.
|
|
810
|
+
// stopAll() must be able to discover a terminal operation whose Docker/fs
|
|
811
|
+
// cleanup is still in flight, and a replacement login must not overlap it.
|
|
812
|
+
this.ensureSettlement(operation);
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
private cleanup(operation: LoginOperation): Promise<void> {
|
|
816
|
+
if (operation.cleanupPromise) return operation.cleanupPromise;
|
|
817
|
+
const processHandle = operation.process;
|
|
818
|
+
const tempDir = operation.tempDir;
|
|
819
|
+
operation.process = null;
|
|
820
|
+
operation.tempDir = null;
|
|
821
|
+
operation.cleanupPromise = (async () => {
|
|
822
|
+
if (processHandle) await this.stopDetachedProcess(processHandle);
|
|
823
|
+
if (tempDir) await this.removeDetachedTemp(tempDir);
|
|
824
|
+
})();
|
|
825
|
+
return operation.cleanupPromise;
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
private ensureSettlement(operation: LoginOperation): Promise<void> {
|
|
829
|
+
if (operation.settlementPromise) return operation.settlementPromise;
|
|
830
|
+
const launch = operation.launchPromise;
|
|
831
|
+
const finish = operation.finishPromise;
|
|
832
|
+
const callback = operation.callbackPromise;
|
|
833
|
+
operation.settlementPromise = (async () => {
|
|
834
|
+
// A detached launch owns any resource that appears after cancellation.
|
|
835
|
+
// Join it before snapshotting cleanup, then stop the container promptly
|
|
836
|
+
// while callback/credential completion settles independently.
|
|
837
|
+
if (launch) await launch;
|
|
838
|
+
const cleanup = this.cleanup(operation);
|
|
839
|
+
await Promise.all([
|
|
840
|
+
cleanup,
|
|
841
|
+
...(finish ? [finish] : []),
|
|
842
|
+
...(callback ? [callback] : []),
|
|
843
|
+
]);
|
|
844
|
+
})().finally(() => {
|
|
845
|
+
if (this.operations.get(operation.opId) === operation) {
|
|
846
|
+
this.operations.delete(operation.opId);
|
|
847
|
+
}
|
|
848
|
+
if (this.engineOperations.get(operation.engine) === operation) {
|
|
849
|
+
this.engineOperations.delete(operation.engine);
|
|
850
|
+
}
|
|
851
|
+
});
|
|
852
|
+
return operation.settlementPromise;
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
private async stopDetachedProcess(
|
|
856
|
+
processHandle: EngineLoginProcess,
|
|
857
|
+
): Promise<void> {
|
|
858
|
+
try {
|
|
859
|
+
await processHandle.stop();
|
|
860
|
+
} catch {
|
|
861
|
+
// A failed exact removal is not absence proof. Serialize a bounded
|
|
862
|
+
// labeled sweep behind any sweep already in flight and retain its false
|
|
863
|
+
// result so the next launch must retry before creating more resources.
|
|
864
|
+
await this.queueReconciliation();
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
private async removeDetachedTemp(path: string): Promise<void> {
|
|
869
|
+
try {
|
|
870
|
+
await this.seams.removeTempDir(path);
|
|
871
|
+
} catch {
|
|
872
|
+
// Codex may have written auth.json here. Queue the same container-first
|
|
873
|
+
// reconciliation used at boot and keep its false result as a launch
|
|
874
|
+
// gate rather than declaring a secret-bearing temp directory settled.
|
|
875
|
+
await this.queueReconciliation();
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
private isCurrent(operation: LoginOperation): boolean {
|
|
880
|
+
return operation.active && this.operations.get(operation.opId) === operation;
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
private clearOperationTimer(operation: LoginOperation): void {
|
|
884
|
+
if (operation.timer === null) return;
|
|
885
|
+
this.seams.timers.clearTimeout(operation.timer);
|
|
886
|
+
operation.timer = null;
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
private runReconciliation(): Promise<boolean> {
|
|
890
|
+
return this.seams.reconcileStaleContainers().then(
|
|
891
|
+
() => true,
|
|
892
|
+
() => false,
|
|
893
|
+
);
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
private queueReconciliation(): Promise<boolean> {
|
|
897
|
+
const queued = this.reconciliation.then(() => this.runReconciliation());
|
|
898
|
+
this.reconciliation = queued;
|
|
899
|
+
return queued;
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
function runBestEffort(work: () => Promise<void>): void {
|
|
904
|
+
try {
|
|
905
|
+
void work().catch(() => {
|
|
906
|
+
// Recovery/reconnect will inject the now-durable credential.
|
|
907
|
+
});
|
|
908
|
+
} catch {
|
|
909
|
+
// Treat a synchronous adapter failure the same as a rejected promise.
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
export function createEngineLoginManager(
|
|
914
|
+
options: EngineLoginManagerOptions,
|
|
915
|
+
): EngineLoginManager {
|
|
916
|
+
return new EngineLoginManager(options);
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
function safeEmit(
|
|
920
|
+
emit: EmitEngineLoginEvent,
|
|
921
|
+
event: EngineLoginEventFrame,
|
|
922
|
+
): void {
|
|
923
|
+
try {
|
|
924
|
+
emit(event);
|
|
925
|
+
} catch {
|
|
926
|
+
// Socket teardown calls stopAll; never turn a closed emitter into a crash.
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
function engineLabel(engine: EngineLoginKind): "Claude" | "Codex" {
|
|
931
|
+
return engine === "claude" ? "Claude" : "Codex";
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
function failedEvent(
|
|
935
|
+
opId: string,
|
|
936
|
+
engine: EngineLoginKind,
|
|
937
|
+
errorCode: EngineLoginErrorCode,
|
|
938
|
+
message: string,
|
|
939
|
+
): EngineLoginEventFrame {
|
|
940
|
+
return {
|
|
941
|
+
kind: "engine.login.event",
|
|
942
|
+
opId,
|
|
943
|
+
engine,
|
|
944
|
+
phase: "failed",
|
|
945
|
+
errorCode,
|
|
946
|
+
message,
|
|
947
|
+
};
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
function authorizeEvent(
|
|
951
|
+
operation: LoginOperation,
|
|
952
|
+
url: string,
|
|
953
|
+
): EngineLoginEventFrame {
|
|
954
|
+
return {
|
|
955
|
+
kind: "engine.login.event",
|
|
956
|
+
opId: operation.opId,
|
|
957
|
+
engine: operation.engine,
|
|
958
|
+
phase: "authorize",
|
|
959
|
+
url,
|
|
960
|
+
};
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
function isValidOneTimeInput(value: string): boolean {
|
|
964
|
+
return (
|
|
965
|
+
value.length >= 1 &&
|
|
966
|
+
value.length <= MAX_ENGINE_LOGIN_TEXT_CHARS &&
|
|
967
|
+
value.trim() === value &&
|
|
968
|
+
!/[\0-\x1f\x7f]/.test(value) &&
|
|
969
|
+
// The PTY echoes input. Never let a caller supply a token-shaped value
|
|
970
|
+
// that the output parser could mistake for the CLI's newly minted token.
|
|
971
|
+
extractClaudeOAuthToken(value) === null
|
|
972
|
+
);
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
function appendUtf8Tail(current: string, chunk: string, maxBytes: number): string {
|
|
976
|
+
const bytes = Buffer.from(current + chunk, "utf8");
|
|
977
|
+
if (bytes.byteLength <= maxBytes) return bytes.toString("utf8");
|
|
978
|
+
return bytes.subarray(bytes.byteLength - maxBytes).toString("utf8");
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
function stripTerminalControl(value: string): string {
|
|
982
|
+
return value
|
|
983
|
+
.replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, "")
|
|
984
|
+
.replace(/\x1b\[[0-9;:?<=>]*[!-/]*[@-~]/g, "")
|
|
985
|
+
.replace(/\x1b[()][0-9A-B]/g, "")
|
|
986
|
+
.replace(/\x1b[78=>DEHM]/g, "")
|
|
987
|
+
.replace(/\r/g, "\n");
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
function candidateHttpsUrls(value: string): string[] {
|
|
991
|
+
const plain = stripTerminalControl(value);
|
|
992
|
+
return [...plain.matchAll(/https:\/\/[^\s<>"'\x00-\x1f]+/g)]
|
|
993
|
+
.map((match) => (match[0] ?? "").replace(/[),.;]+$/g, ""))
|
|
994
|
+
.filter(Boolean);
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
function parseSafeHttpsUrl(value: string): URL | null {
|
|
998
|
+
if (value.length < 1 || value.length > MAX_ENGINE_LOGIN_URL_CHARS) return null;
|
|
999
|
+
try {
|
|
1000
|
+
const url = new URL(value);
|
|
1001
|
+
if (
|
|
1002
|
+
url.protocol !== "https:" ||
|
|
1003
|
+
url.username ||
|
|
1004
|
+
url.password ||
|
|
1005
|
+
!url.hostname
|
|
1006
|
+
) {
|
|
1007
|
+
return null;
|
|
1008
|
+
}
|
|
1009
|
+
return url.toString().length <= MAX_ENGINE_LOGIN_URL_CHARS ? url : null;
|
|
1010
|
+
} catch {
|
|
1011
|
+
return null;
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
/** Exported narrow parsers make secret-boundary regressions easy to fuzz. */
|
|
1016
|
+
export function findSafeHttpsUrl(value: string): string | null {
|
|
1017
|
+
for (const candidate of candidateHttpsUrls(value)) {
|
|
1018
|
+
const parsed = parseSafeHttpsUrl(candidate);
|
|
1019
|
+
if (parsed) return parsed.toString();
|
|
1020
|
+
}
|
|
1021
|
+
return null;
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
export function extractClaudeOAuthToken(value: string): string | null {
|
|
1025
|
+
const plain = stripTerminalControl(value);
|
|
1026
|
+
const match = /(?:^|[^A-Za-z0-9_-])(sk-ant-[A-Za-z0-9_-]{16,4096})(?![A-Za-z0-9_-])/.exec(
|
|
1027
|
+
plain,
|
|
1028
|
+
);
|
|
1029
|
+
return match?.[1] ?? null;
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
function isLocalhost(hostname: string): boolean {
|
|
1033
|
+
const normalized = hostname.toLowerCase().replace(/^\[|\]$/g, "");
|
|
1034
|
+
return (
|
|
1035
|
+
normalized === "localhost" ||
|
|
1036
|
+
normalized === "127.0.0.1" ||
|
|
1037
|
+
normalized === "::1"
|
|
1038
|
+
);
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
function codexCallbackTarget(authorizeUrl: URL): CodexCallbackTarget | null {
|
|
1042
|
+
const redirectValues = [
|
|
1043
|
+
...authorizeUrl.searchParams.getAll("redirect_uri"),
|
|
1044
|
+
...authorizeUrl.searchParams.getAll("redirect_url"),
|
|
1045
|
+
];
|
|
1046
|
+
const states = authorizeUrl.searchParams.getAll("state");
|
|
1047
|
+
if (
|
|
1048
|
+
redirectValues.length !== 1 ||
|
|
1049
|
+
!redirectValues[0] ||
|
|
1050
|
+
states.length !== 1 ||
|
|
1051
|
+
!states[0] ||
|
|
1052
|
+
states[0].length > MAX_ENGINE_LOGIN_QUERY_CHARS
|
|
1053
|
+
) {
|
|
1054
|
+
return null;
|
|
1055
|
+
}
|
|
1056
|
+
const redirectValue = redirectValues[0];
|
|
1057
|
+
let redirect: URL;
|
|
1058
|
+
try {
|
|
1059
|
+
redirect = new URL(redirectValue);
|
|
1060
|
+
} catch {
|
|
1061
|
+
return null;
|
|
1062
|
+
}
|
|
1063
|
+
if (
|
|
1064
|
+
redirect.protocol !== "http:" ||
|
|
1065
|
+
!isLocalhost(redirect.hostname) ||
|
|
1066
|
+
!redirect.port ||
|
|
1067
|
+
redirect.username ||
|
|
1068
|
+
redirect.password ||
|
|
1069
|
+
redirect.search ||
|
|
1070
|
+
redirect.hash ||
|
|
1071
|
+
!/^\d{1,5}$/.test(redirect.port)
|
|
1072
|
+
) {
|
|
1073
|
+
return null;
|
|
1074
|
+
}
|
|
1075
|
+
const port = Number(redirect.port);
|
|
1076
|
+
if (port < 1 || port > 65_535) return null;
|
|
1077
|
+
return {
|
|
1078
|
+
origin: redirect.origin,
|
|
1079
|
+
pathname: redirect.pathname || "/",
|
|
1080
|
+
port: redirect.port,
|
|
1081
|
+
hostname: redirect.hostname,
|
|
1082
|
+
state: states[0],
|
|
1083
|
+
};
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
function findCodexAuthorization(
|
|
1087
|
+
value: string,
|
|
1088
|
+
): { authorizeUrl: string; target: CodexCallbackTarget } | null {
|
|
1089
|
+
for (const candidate of candidateHttpsUrls(value)) {
|
|
1090
|
+
const authorizeUrl = parseSafeHttpsUrl(candidate);
|
|
1091
|
+
if (!authorizeUrl) continue;
|
|
1092
|
+
const target = codexCallbackTarget(authorizeUrl);
|
|
1093
|
+
if (target) return { authorizeUrl: authorizeUrl.toString(), target };
|
|
1094
|
+
}
|
|
1095
|
+
return null;
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
function validateCodexCallback(
|
|
1099
|
+
value: string,
|
|
1100
|
+
target: CodexCallbackTarget,
|
|
1101
|
+
): string | null {
|
|
1102
|
+
if (
|
|
1103
|
+
value.length < 1 ||
|
|
1104
|
+
value.length > MAX_ENGINE_LOGIN_QUERY_CHARS ||
|
|
1105
|
+
value.trim() !== value ||
|
|
1106
|
+
/[\0\r\n]/.test(value)
|
|
1107
|
+
) {
|
|
1108
|
+
return null;
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
let query: string;
|
|
1112
|
+
if (/^https?:\/\//i.test(value)) {
|
|
1113
|
+
let callback: URL;
|
|
1114
|
+
try {
|
|
1115
|
+
callback = new URL(value);
|
|
1116
|
+
} catch {
|
|
1117
|
+
return null;
|
|
1118
|
+
}
|
|
1119
|
+
if (
|
|
1120
|
+
callback.protocol !== "http:" ||
|
|
1121
|
+
!isLocalhost(callback.hostname) ||
|
|
1122
|
+
callback.hostname !== target.hostname ||
|
|
1123
|
+
callback.port !== target.port ||
|
|
1124
|
+
callback.pathname !== target.pathname ||
|
|
1125
|
+
callback.username ||
|
|
1126
|
+
callback.password ||
|
|
1127
|
+
callback.hash
|
|
1128
|
+
) {
|
|
1129
|
+
return null;
|
|
1130
|
+
}
|
|
1131
|
+
query = callback.search.slice(1);
|
|
1132
|
+
} else {
|
|
1133
|
+
if (value.includes("#") || /^[A-Za-z][A-Za-z0-9+.-]*:/.test(value)) {
|
|
1134
|
+
return null;
|
|
1135
|
+
}
|
|
1136
|
+
query = value.startsWith("?") ? value.slice(1) : value;
|
|
1137
|
+
}
|
|
1138
|
+
if (!query || query.length > MAX_ENGINE_LOGIN_QUERY_CHARS) return null;
|
|
1139
|
+
|
|
1140
|
+
const params = new URLSearchParams(query);
|
|
1141
|
+
const codes = params.getAll("code");
|
|
1142
|
+
const states = params.getAll("state");
|
|
1143
|
+
if (
|
|
1144
|
+
codes.length !== 1 ||
|
|
1145
|
+
!codes[0] ||
|
|
1146
|
+
states.length !== 1 ||
|
|
1147
|
+
!states[0] ||
|
|
1148
|
+
states[0] !== target.state
|
|
1149
|
+
) {
|
|
1150
|
+
return null;
|
|
1151
|
+
}
|
|
1152
|
+
const normalized = params.toString();
|
|
1153
|
+
return normalized.length <= MAX_ENGINE_LOGIN_QUERY_CHARS ? normalized : null;
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
function callbackReplayUrl(
|
|
1157
|
+
target: CodexCallbackTarget,
|
|
1158
|
+
query: string,
|
|
1159
|
+
): string {
|
|
1160
|
+
const callback = new URL(target.pathname, target.origin);
|
|
1161
|
+
callback.search = query;
|
|
1162
|
+
return callback.toString();
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
async function startLoginContainer(
|
|
1166
|
+
request: EngineLoginContainerRequest,
|
|
1167
|
+
onOutput: (chunk: string) => void,
|
|
1168
|
+
): Promise<EngineLoginProcess> {
|
|
1169
|
+
const args = engineLoginContainerArgs(request);
|
|
1170
|
+
const child = spawn(engineLoginBackend().command, args, {
|
|
1171
|
+
shell: false,
|
|
1172
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
1173
|
+
});
|
|
1174
|
+
child.stdout.setEncoding("utf8");
|
|
1175
|
+
child.stderr.setEncoding("utf8");
|
|
1176
|
+
child.stdout.on("data", (chunk: string) => onOutput(chunk));
|
|
1177
|
+
child.stderr.on("data", (chunk: string) => onOutput(chunk));
|
|
1178
|
+
child.stdin.on("error", () => {});
|
|
1179
|
+
|
|
1180
|
+
let stopped = false;
|
|
1181
|
+
let settled = false;
|
|
1182
|
+
let resolveCompletion: (result: EngineLoginProcessResult) => void = () => {};
|
|
1183
|
+
const completion = new Promise<EngineLoginProcessResult>((resolve) => {
|
|
1184
|
+
resolveCompletion = resolve;
|
|
1185
|
+
});
|
|
1186
|
+
const settle = (result: EngineLoginProcessResult): void => {
|
|
1187
|
+
if (settled) return;
|
|
1188
|
+
settled = true;
|
|
1189
|
+
resolveCompletion(result);
|
|
1190
|
+
};
|
|
1191
|
+
child.once("error", () => settle({ code: null, spawnFailed: true }));
|
|
1192
|
+
child.once("close", (code) => settle({ code }));
|
|
1193
|
+
|
|
1194
|
+
return {
|
|
1195
|
+
containerName: request.containerName,
|
|
1196
|
+
completion,
|
|
1197
|
+
writeInput(value) {
|
|
1198
|
+
if (stopped || child.stdin.destroyed) throw new Error("login input closed");
|
|
1199
|
+
child.stdin.write(value);
|
|
1200
|
+
},
|
|
1201
|
+
async stop() {
|
|
1202
|
+
if (stopped) return;
|
|
1203
|
+
stopped = true;
|
|
1204
|
+
await stopEngineLoginContainerProcess({
|
|
1205
|
+
completion,
|
|
1206
|
+
endInput: () => child.stdin.end(),
|
|
1207
|
+
terminate: () => child.kill("SIGTERM"),
|
|
1208
|
+
kill: () => child.kill("SIGKILL"),
|
|
1209
|
+
remove: () => removeEngineLoginContainer(request.containerName),
|
|
1210
|
+
});
|
|
1211
|
+
},
|
|
1212
|
+
};
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
export interface EngineLoginProcessStopControl {
|
|
1216
|
+
readonly completion: Promise<EngineLoginProcessResult>;
|
|
1217
|
+
endInput(): void;
|
|
1218
|
+
terminate(): void;
|
|
1219
|
+
kill(): void;
|
|
1220
|
+
remove(): Promise<void>;
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
/**
|
|
1224
|
+
* Close the docker-run client before proving/removing the daemon resource.
|
|
1225
|
+
* Otherwise an in-flight `docker run` can create the container just after an
|
|
1226
|
+
* early absence check, orphaning it across generation teardown.
|
|
1227
|
+
*/
|
|
1228
|
+
export async function stopEngineLoginContainerProcess(
|
|
1229
|
+
control: EngineLoginProcessStopControl,
|
|
1230
|
+
): Promise<void> {
|
|
1231
|
+
control.endInput();
|
|
1232
|
+
control.terminate();
|
|
1233
|
+
if (
|
|
1234
|
+
!(await promiseSettlesWithin(
|
|
1235
|
+
control.completion,
|
|
1236
|
+
DOCKER_RUN_STOP_GRACE_MS,
|
|
1237
|
+
))
|
|
1238
|
+
) {
|
|
1239
|
+
control.kill();
|
|
1240
|
+
if (
|
|
1241
|
+
!(await promiseSettlesWithin(
|
|
1242
|
+
control.completion,
|
|
1243
|
+
DOCKER_RUN_STOP_GRACE_MS,
|
|
1244
|
+
))
|
|
1245
|
+
) {
|
|
1246
|
+
throw new Error("login docker client did not stop");
|
|
1247
|
+
}
|
|
1248
|
+
}
|
|
1249
|
+
await control.remove();
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
async function promiseSettlesWithin(
|
|
1253
|
+
promise: Promise<unknown>,
|
|
1254
|
+
timeoutMs: number,
|
|
1255
|
+
): Promise<boolean> {
|
|
1256
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
1257
|
+
try {
|
|
1258
|
+
return await Promise.race([
|
|
1259
|
+
promise.then(
|
|
1260
|
+
() => true,
|
|
1261
|
+
() => true,
|
|
1262
|
+
),
|
|
1263
|
+
new Promise<boolean>((resolve) => {
|
|
1264
|
+
timer = setTimeout(() => resolve(false), timeoutMs);
|
|
1265
|
+
timer.unref?.();
|
|
1266
|
+
}),
|
|
1267
|
+
]);
|
|
1268
|
+
} finally {
|
|
1269
|
+
if (timer) clearTimeout(timer);
|
|
1270
|
+
}
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
/** Pure command builder kept exported for the isolation regression test. */
|
|
1274
|
+
export function engineLoginContainerArgs(
|
|
1275
|
+
request: EngineLoginContainerRequest,
|
|
1276
|
+
): string[] {
|
|
1277
|
+
if (/[,\0\r\n]/.test(request.tempDir)) {
|
|
1278
|
+
throw new Error("unsafe temporary path");
|
|
1279
|
+
}
|
|
1280
|
+
const uidArgs =
|
|
1281
|
+
typeof process.getuid === "function" && typeof process.getgid === "function"
|
|
1282
|
+
? ["--user", `${process.getuid()}:${process.getgid()}`]
|
|
1283
|
+
: [];
|
|
1284
|
+
const command =
|
|
1285
|
+
request.engine === "claude"
|
|
1286
|
+
? [
|
|
1287
|
+
"/usr/bin/script",
|
|
1288
|
+
"-qefc",
|
|
1289
|
+
"exec claude setup-token",
|
|
1290
|
+
"/dev/null",
|
|
1291
|
+
]
|
|
1292
|
+
: ["codex", "login"];
|
|
1293
|
+
if (engineLoginBackend().apple) {
|
|
1294
|
+
// Same containment contract, Apple dialect: read-only root, all caps
|
|
1295
|
+
// dropped, a plain tmpfs at /tmp (the Apple CLI takes no mount options —
|
|
1296
|
+
// the VM boundary already denies device/suid tricks, which is also why
|
|
1297
|
+
// the missing no-new-privileges knob costs nothing here), and -v mounts
|
|
1298
|
+
// for the shared runtime volume + private temp leaf. The container runs
|
|
1299
|
+
// as node: ownership maps transparently across the VM boundary, so the
|
|
1300
|
+
// host uid/gid pin that Docker needs would be meaningless.
|
|
1301
|
+
return [
|
|
1302
|
+
"run",
|
|
1303
|
+
"--rm",
|
|
1304
|
+
"--name",
|
|
1305
|
+
request.containerName,
|
|
1306
|
+
"--label",
|
|
1307
|
+
`${ENGINE_LOGIN_CONTAINER_LABEL}=${ENGINE_LOGIN_CONTAINER_LABEL_VALUE}`,
|
|
1308
|
+
"--init",
|
|
1309
|
+
"--interactive",
|
|
1310
|
+
"--read-only",
|
|
1311
|
+
"--cap-drop",
|
|
1312
|
+
"ALL",
|
|
1313
|
+
"--tmpfs",
|
|
1314
|
+
"/tmp",
|
|
1315
|
+
"-v",
|
|
1316
|
+
`${ASDF_DATA_VOLUME}:/opt/asdf-data:ro`,
|
|
1317
|
+
"-v",
|
|
1318
|
+
`${request.tempDir}:${LOGIN_MOUNT}`,
|
|
1319
|
+
"--user",
|
|
1320
|
+
"node",
|
|
1321
|
+
"--workdir",
|
|
1322
|
+
"/home/node",
|
|
1323
|
+
"--env",
|
|
1324
|
+
`HOME=${LOGIN_HOME}`,
|
|
1325
|
+
"--env",
|
|
1326
|
+
`XDG_CONFIG_HOME=${LOGIN_HOME}/.config`,
|
|
1327
|
+
"--env",
|
|
1328
|
+
`XDG_DATA_HOME=${LOGIN_HOME}/.local/share`,
|
|
1329
|
+
"--env",
|
|
1330
|
+
`XDG_CACHE_HOME=${LOGIN_HOME}/.cache`,
|
|
1331
|
+
"--env",
|
|
1332
|
+
`CODEX_HOME=${CODEX_HOME}`,
|
|
1333
|
+
"--env",
|
|
1334
|
+
"ASDF_CONFIG_FILE=/dev/null",
|
|
1335
|
+
"--env",
|
|
1336
|
+
"ASDF_DEFAULT_TOOL_VERSIONS_FILENAME=.tool-versions",
|
|
1337
|
+
STANDARD_IMAGE_TAG,
|
|
1338
|
+
...command,
|
|
1339
|
+
];
|
|
1340
|
+
}
|
|
1341
|
+
return [
|
|
1342
|
+
"run",
|
|
1343
|
+
"--rm",
|
|
1344
|
+
"--name",
|
|
1345
|
+
request.containerName,
|
|
1346
|
+
"--label",
|
|
1347
|
+
`${ENGINE_LOGIN_CONTAINER_LABEL}=${ENGINE_LOGIN_CONTAINER_LABEL_VALUE}`,
|
|
1348
|
+
"--init",
|
|
1349
|
+
"--interactive",
|
|
1350
|
+
"--read-only",
|
|
1351
|
+
"--cap-drop",
|
|
1352
|
+
"ALL",
|
|
1353
|
+
"--security-opt",
|
|
1354
|
+
"no-new-privileges:true",
|
|
1355
|
+
"--tmpfs",
|
|
1356
|
+
"/tmp:rw,nosuid,nodev,noexec,size=64m,mode=1777",
|
|
1357
|
+
"--mount",
|
|
1358
|
+
`type=volume,src=${ASDF_DATA_VOLUME},dst=/opt/asdf-data,readonly`,
|
|
1359
|
+
"--mount",
|
|
1360
|
+
`type=bind,src=${request.tempDir},dst=${LOGIN_MOUNT}`,
|
|
1361
|
+
...uidArgs,
|
|
1362
|
+
"--workdir",
|
|
1363
|
+
"/home/node",
|
|
1364
|
+
"--env",
|
|
1365
|
+
`HOME=${LOGIN_HOME}`,
|
|
1366
|
+
"--env",
|
|
1367
|
+
`XDG_CONFIG_HOME=${LOGIN_HOME}/.config`,
|
|
1368
|
+
"--env",
|
|
1369
|
+
`XDG_DATA_HOME=${LOGIN_HOME}/.local/share`,
|
|
1370
|
+
"--env",
|
|
1371
|
+
`XDG_CACHE_HOME=${LOGIN_HOME}/.cache`,
|
|
1372
|
+
"--env",
|
|
1373
|
+
`CODEX_HOME=${CODEX_HOME}`,
|
|
1374
|
+
"--env",
|
|
1375
|
+
"ASDF_CONFIG_FILE=/dev/null",
|
|
1376
|
+
"--env",
|
|
1377
|
+
"ASDF_DEFAULT_TOOL_VERSIONS_FILENAME=.tool-versions",
|
|
1378
|
+
STANDARD_IMAGE_TAG,
|
|
1379
|
+
...command,
|
|
1380
|
+
];
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
async function replayCodexCallback(
|
|
1384
|
+
containerName: string,
|
|
1385
|
+
callbackUrl: string,
|
|
1386
|
+
): Promise<void> {
|
|
1387
|
+
const request = codexCallbackReplayDockerRequest(containerName, callbackUrl);
|
|
1388
|
+
const result = await defaultEngineLoginDockerExec(request.args, {
|
|
1389
|
+
input: request.input,
|
|
1390
|
+
timeoutMs: DOCKER_CONTROL_TIMEOUT_MS,
|
|
1391
|
+
maxOutputBytes: 8 * 1_024,
|
|
1392
|
+
});
|
|
1393
|
+
if (result.status !== 0) throw new Error("callback replay failed");
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
/**
|
|
1397
|
+
* Docker and the in-container curl process receive a fixed argv. The full
|
|
1398
|
+
* callback (including code/state) exists only in the child's stdin, so it is
|
|
1399
|
+
* absent from process listings, daemon metadata, and Docker error messages.
|
|
1400
|
+
*/
|
|
1401
|
+
export function codexCallbackReplayDockerRequest(
|
|
1402
|
+
containerName: string,
|
|
1403
|
+
callbackUrl: string,
|
|
1404
|
+
): { args: string[]; input: string } {
|
|
1405
|
+
if (!isEngineLoginContainerName(containerName)) {
|
|
1406
|
+
throw new Error("invalid login container name");
|
|
1407
|
+
}
|
|
1408
|
+
if (/[\0\r\n]/.test(callbackUrl)) {
|
|
1409
|
+
throw new Error("invalid callback URL");
|
|
1410
|
+
}
|
|
1411
|
+
const configUrl = callbackUrl
|
|
1412
|
+
.replace(/\\/g, "\\\\")
|
|
1413
|
+
.replace(/"/g, '\\"');
|
|
1414
|
+
return {
|
|
1415
|
+
args: [
|
|
1416
|
+
"exec",
|
|
1417
|
+
"--interactive",
|
|
1418
|
+
containerName,
|
|
1419
|
+
"/usr/bin/curl",
|
|
1420
|
+
"--fail",
|
|
1421
|
+
"--silent",
|
|
1422
|
+
"--show-error",
|
|
1423
|
+
"--max-time",
|
|
1424
|
+
"10",
|
|
1425
|
+
"--output",
|
|
1426
|
+
"/dev/null",
|
|
1427
|
+
"--config",
|
|
1428
|
+
"-",
|
|
1429
|
+
],
|
|
1430
|
+
input: `url = "${configUrl}"\n`,
|
|
1431
|
+
};
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
function isEngineLoginContainerName(value: string): boolean {
|
|
1435
|
+
return /^uai-login-(?:claude|codex)-[0-9a-f]{16}$/.test(value);
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1438
|
+
/** The CLI that owns login containers (ADR-106): the ambient docker client,
|
|
1439
|
+
* or the bundled Apple CLI when that backend is pinned. Resolved per call so
|
|
1440
|
+
* an HMR reload cannot strand a stale path; the pin itself never changes
|
|
1441
|
+
* within a process. */
|
|
1442
|
+
function engineLoginBackend(): { command: string; apple: boolean } {
|
|
1443
|
+
if (pinnedContainerRuntimeProvider() === "apple-container") {
|
|
1444
|
+
const binding = appleContainerRuntimeBinding();
|
|
1445
|
+
if (binding !== null) {
|
|
1446
|
+
return { command: binding.containerCliPath, apple: true };
|
|
1447
|
+
}
|
|
1448
|
+
}
|
|
1449
|
+
return { command: "docker", apple: false };
|
|
1450
|
+
}
|
|
1451
|
+
|
|
1452
|
+
const defaultEngineLoginDockerExec: EngineLoginDockerExec = (args, options) => {
|
|
1453
|
+
const backend = engineLoginBackend();
|
|
1454
|
+
if (!backend.apple) return dockerCli(args, options);
|
|
1455
|
+
return new Promise((resolveExec) => {
|
|
1456
|
+
const child = spawn(backend.command, args, {
|
|
1457
|
+
shell: false,
|
|
1458
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
1459
|
+
});
|
|
1460
|
+
let stdout = "";
|
|
1461
|
+
let stderr = "";
|
|
1462
|
+
let settled = false;
|
|
1463
|
+
const timer = setTimeout(() => {
|
|
1464
|
+
child.kill("SIGKILL");
|
|
1465
|
+
const settle = setTimeout(() => settleResult(null), 5_000);
|
|
1466
|
+
settle.unref?.();
|
|
1467
|
+
}, options?.timeoutMs ?? 30_000);
|
|
1468
|
+
child.stdout.setEncoding("utf8");
|
|
1469
|
+
child.stderr.setEncoding("utf8");
|
|
1470
|
+
child.stdout.on("data", (chunk: string) => (stdout += chunk));
|
|
1471
|
+
child.stderr.on("data", (chunk: string) => (stderr += chunk));
|
|
1472
|
+
child.stdin.on("error", () => {});
|
|
1473
|
+
if (options?.input !== undefined) child.stdin.write(options.input);
|
|
1474
|
+
child.stdin.end();
|
|
1475
|
+
const settleResult = (status: number | null): void => {
|
|
1476
|
+
if (settled) return;
|
|
1477
|
+
settled = true;
|
|
1478
|
+
clearTimeout(timer);
|
|
1479
|
+
resolveExec({ status, stdout, stderr });
|
|
1480
|
+
};
|
|
1481
|
+
child.on("error", () => settleResult(null));
|
|
1482
|
+
child.on("close", (code) => settleResult(code));
|
|
1483
|
+
});
|
|
1484
|
+
};
|
|
1485
|
+
|
|
1486
|
+
/** The Apple CLI exits nonzero for both a missing container and a broken
|
|
1487
|
+
* apiserver; only the not-found error naming this exact container proves
|
|
1488
|
+
* absence. */
|
|
1489
|
+
function appleReportsAbsent(
|
|
1490
|
+
result: { status: number | null; stdout: string; stderr: string },
|
|
1491
|
+
containerName: string,
|
|
1492
|
+
): boolean {
|
|
1493
|
+
// The complete bounded error line, never a substring: a not-found answer
|
|
1494
|
+
// for `<name>-shadow` must not prove the absence of `<name>`.
|
|
1495
|
+
return (
|
|
1496
|
+
result.status !== 0 &&
|
|
1497
|
+
result.stdout.trim() === "" &&
|
|
1498
|
+
result.stderr
|
|
1499
|
+
.split("\n")
|
|
1500
|
+
.some(
|
|
1501
|
+
(line) => line.trim() === `Error: container not found: ${containerName}`,
|
|
1502
|
+
)
|
|
1503
|
+
);
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1506
|
+
/** Force-remove one owned container, proving either success or exact absence. */
|
|
1507
|
+
export async function removeEngineLoginContainer(
|
|
1508
|
+
containerName: string,
|
|
1509
|
+
exec: EngineLoginDockerExec = defaultEngineLoginDockerExec,
|
|
1510
|
+
): Promise<void> {
|
|
1511
|
+
if (!isEngineLoginContainerName(containerName)) {
|
|
1512
|
+
throw new Error("invalid login container name");
|
|
1513
|
+
}
|
|
1514
|
+
const apple = engineLoginBackend().apple;
|
|
1515
|
+
for (let attempt = 0; attempt < DOCKER_CLEANUP_ATTEMPTS; attempt += 1) {
|
|
1516
|
+
let observedLabel: string | null = null;
|
|
1517
|
+
if (apple) {
|
|
1518
|
+
const inspected = await exec(["inspect", containerName], {
|
|
1519
|
+
timeoutMs: DOCKER_CLEANUP_TIMEOUT_MS,
|
|
1520
|
+
maxOutputBytes: 256 * 1_024,
|
|
1521
|
+
});
|
|
1522
|
+
if (inspected.status !== 0) {
|
|
1523
|
+
if (await engineLoginContainerIsAbsent(containerName, exec)) return;
|
|
1524
|
+
continue;
|
|
1525
|
+
}
|
|
1526
|
+
try {
|
|
1527
|
+
const parsed = JSON.parse(inspected.stdout) as Array<{
|
|
1528
|
+
configuration?: { labels?: Record<string, unknown> };
|
|
1529
|
+
}>;
|
|
1530
|
+
const label =
|
|
1531
|
+
parsed[0]?.configuration?.labels?.[ENGINE_LOGIN_CONTAINER_LABEL];
|
|
1532
|
+
observedLabel = typeof label === "string" ? label : null;
|
|
1533
|
+
} catch {
|
|
1534
|
+
continue;
|
|
1535
|
+
}
|
|
1536
|
+
} else {
|
|
1537
|
+
const inspected = await exec(
|
|
1538
|
+
[
|
|
1539
|
+
"container",
|
|
1540
|
+
"inspect",
|
|
1541
|
+
"--format",
|
|
1542
|
+
`{{ index .Config.Labels \"${ENGINE_LOGIN_CONTAINER_LABEL}\" }}`,
|
|
1543
|
+
containerName,
|
|
1544
|
+
],
|
|
1545
|
+
{
|
|
1546
|
+
timeoutMs: DOCKER_CLEANUP_TIMEOUT_MS,
|
|
1547
|
+
maxOutputBytes: 8 * 1_024,
|
|
1548
|
+
},
|
|
1549
|
+
);
|
|
1550
|
+
if (inspected.status !== 0) {
|
|
1551
|
+
if (await engineLoginContainerIsAbsent(containerName, exec)) return;
|
|
1552
|
+
continue;
|
|
1553
|
+
}
|
|
1554
|
+
observedLabel = inspected.stdout.trim();
|
|
1555
|
+
}
|
|
1556
|
+
if (observedLabel !== ENGINE_LOGIN_CONTAINER_LABEL_VALUE) {
|
|
1557
|
+
throw new Error("login container ownership could not be confirmed");
|
|
1558
|
+
}
|
|
1559
|
+
|
|
1560
|
+
const removed = await exec(
|
|
1561
|
+
apple
|
|
1562
|
+
? ["delete", "--force", containerName]
|
|
1563
|
+
: ["rm", "--force", containerName],
|
|
1564
|
+
{
|
|
1565
|
+
timeoutMs: DOCKER_CLEANUP_TIMEOUT_MS,
|
|
1566
|
+
maxOutputBytes: 8 * 1_024,
|
|
1567
|
+
},
|
|
1568
|
+
);
|
|
1569
|
+
// Even a zero exit is followed by an exact-name absence proof. This closes
|
|
1570
|
+
// daemon/client races and detects a same-name replacement before cleanup
|
|
1571
|
+
// can be declared settled.
|
|
1572
|
+
if (await engineLoginContainerIsAbsent(containerName, exec)) return;
|
|
1573
|
+
}
|
|
1574
|
+
throw new Error("login container cleanup could not be confirmed");
|
|
1575
|
+
}
|
|
1576
|
+
|
|
1577
|
+
async function engineLoginContainerIsAbsent(
|
|
1578
|
+
containerName: string,
|
|
1579
|
+
exec: EngineLoginDockerExec,
|
|
1580
|
+
): Promise<boolean> {
|
|
1581
|
+
if (engineLoginBackend().apple) {
|
|
1582
|
+
const inspected = await exec(["inspect", containerName], {
|
|
1583
|
+
timeoutMs: DOCKER_CLEANUP_TIMEOUT_MS,
|
|
1584
|
+
maxOutputBytes: 256 * 1_024,
|
|
1585
|
+
});
|
|
1586
|
+
return appleReportsAbsent(inspected, containerName);
|
|
1587
|
+
}
|
|
1588
|
+
const inventory = await exec(
|
|
1589
|
+
[
|
|
1590
|
+
"container",
|
|
1591
|
+
"ls",
|
|
1592
|
+
"--all",
|
|
1593
|
+
"--quiet",
|
|
1594
|
+
"--filter",
|
|
1595
|
+
`name=^/${containerName}$`,
|
|
1596
|
+
],
|
|
1597
|
+
{
|
|
1598
|
+
timeoutMs: DOCKER_CLEANUP_TIMEOUT_MS,
|
|
1599
|
+
maxOutputBytes: 8 * 1_024,
|
|
1600
|
+
},
|
|
1601
|
+
);
|
|
1602
|
+
return inventory.status === 0 && inventory.stdout.trim() === "";
|
|
1603
|
+
}
|
|
1604
|
+
|
|
1605
|
+
/** Bounded boot sweep for containers left by a killed host or daemon blip. */
|
|
1606
|
+
export async function reconcileStaleEngineLoginContainers(
|
|
1607
|
+
exec: EngineLoginDockerExec = defaultEngineLoginDockerExec,
|
|
1608
|
+
): Promise<void> {
|
|
1609
|
+
let names: string[] | null = null;
|
|
1610
|
+
const apple = engineLoginBackend().apple;
|
|
1611
|
+
for (let attempt = 0; attempt < DOCKER_CLEANUP_ATTEMPTS; attempt += 1) {
|
|
1612
|
+
if (apple) {
|
|
1613
|
+
// No server-side label filter exists; list everything and select by the
|
|
1614
|
+
// ownership label client-side (the container id IS its name).
|
|
1615
|
+
const inventory = await exec(["list", "--all", "--format", "json"], {
|
|
1616
|
+
timeoutMs: DOCKER_CLEANUP_TIMEOUT_MS,
|
|
1617
|
+
maxOutputBytes: 4 * 1_024 * 1_024,
|
|
1618
|
+
});
|
|
1619
|
+
if (inventory.status !== 0) continue;
|
|
1620
|
+
try {
|
|
1621
|
+
const parsed = JSON.parse(inventory.stdout) as Array<{
|
|
1622
|
+
configuration?: { id?: unknown; labels?: Record<string, unknown> };
|
|
1623
|
+
}>;
|
|
1624
|
+
names = parsed
|
|
1625
|
+
.filter(
|
|
1626
|
+
(item) =>
|
|
1627
|
+
item.configuration?.labels?.[ENGINE_LOGIN_CONTAINER_LABEL] ===
|
|
1628
|
+
ENGINE_LOGIN_CONTAINER_LABEL_VALUE,
|
|
1629
|
+
)
|
|
1630
|
+
.map((item) => item.configuration?.id)
|
|
1631
|
+
.filter((id): id is string => typeof id === "string");
|
|
1632
|
+
} catch {
|
|
1633
|
+
continue;
|
|
1634
|
+
}
|
|
1635
|
+
break;
|
|
1636
|
+
}
|
|
1637
|
+
const inventory = await exec(
|
|
1638
|
+
[
|
|
1639
|
+
"container",
|
|
1640
|
+
"ls",
|
|
1641
|
+
"--all",
|
|
1642
|
+
"--format",
|
|
1643
|
+
"{{.Names}}",
|
|
1644
|
+
"--filter",
|
|
1645
|
+
`label=${ENGINE_LOGIN_CONTAINER_LABEL}=${ENGINE_LOGIN_CONTAINER_LABEL_VALUE}`,
|
|
1646
|
+
],
|
|
1647
|
+
{
|
|
1648
|
+
timeoutMs: DOCKER_CLEANUP_TIMEOUT_MS,
|
|
1649
|
+
maxOutputBytes: 64 * 1_024,
|
|
1650
|
+
},
|
|
1651
|
+
);
|
|
1652
|
+
if (inventory.status !== 0) continue;
|
|
1653
|
+
names = inventory.stdout
|
|
1654
|
+
.split("\n")
|
|
1655
|
+
.map((name) => name.trim())
|
|
1656
|
+
.filter(Boolean);
|
|
1657
|
+
break;
|
|
1658
|
+
}
|
|
1659
|
+
if (names === null) {
|
|
1660
|
+
throw new Error("stale login container inventory unavailable");
|
|
1661
|
+
}
|
|
1662
|
+
if (
|
|
1663
|
+
names.length > MAX_STALE_LOGIN_CONTAINERS ||
|
|
1664
|
+
names.some((name) => !isEngineLoginContainerName(name))
|
|
1665
|
+
) {
|
|
1666
|
+
throw new Error("stale login container inventory is invalid");
|
|
1667
|
+
}
|
|
1668
|
+
for (const name of names) await removeEngineLoginContainer(name, exec);
|
|
1669
|
+
}
|
|
1670
|
+
|
|
1671
|
+
function engineLoginTempMarkerBody(name: string): string {
|
|
1672
|
+
return `${ENGINE_LOGIN_TEMP_MARKER_VERSION}:${name}\n`;
|
|
1673
|
+
}
|
|
1674
|
+
|
|
1675
|
+
/** Create the private login workspace and its no-follow ownership marker. */
|
|
1676
|
+
export async function createEngineLoginTempDir(
|
|
1677
|
+
requestedRoot?: string,
|
|
1678
|
+
): Promise<string> {
|
|
1679
|
+
const root = requestedRoot ?? tmpdir();
|
|
1680
|
+
if (requestedRoot !== undefined) await ensurePrivateEngineLoginTempRoot(root);
|
|
1681
|
+
const path = await mkdtemp(join(root, "uai-engine-login-"));
|
|
1682
|
+
let marker: Awaited<ReturnType<typeof open>> | null = null;
|
|
1683
|
+
try {
|
|
1684
|
+
const name = basename(path);
|
|
1685
|
+
if (!ENGINE_LOGIN_TEMP_DIR_PATTERN.test(name)) {
|
|
1686
|
+
throw new Error("login temp directory name is invalid");
|
|
1687
|
+
}
|
|
1688
|
+
await chmod(path, 0o700);
|
|
1689
|
+
marker = await open(join(path, ENGINE_LOGIN_TEMP_MARKER), "wx", 0o600);
|
|
1690
|
+
await marker.writeFile(engineLoginTempMarkerBody(name), "utf8");
|
|
1691
|
+
await marker.sync();
|
|
1692
|
+
await marker.close();
|
|
1693
|
+
marker = null;
|
|
1694
|
+
await syncDirectory(path);
|
|
1695
|
+
return path;
|
|
1696
|
+
} catch (error) {
|
|
1697
|
+
if (marker) await marker.close().catch(() => undefined);
|
|
1698
|
+
await rm(path, { recursive: true, force: true }).catch(() => undefined);
|
|
1699
|
+
throw error;
|
|
1700
|
+
}
|
|
1701
|
+
}
|
|
1702
|
+
|
|
1703
|
+
/** Prepare only the dedicated bind leaf, never a broader Uai/home directory. */
|
|
1704
|
+
async function ensurePrivateEngineLoginTempRoot(root: string): Promise<void> {
|
|
1705
|
+
if (
|
|
1706
|
+
!root.startsWith("/") ||
|
|
1707
|
+
resolve(root) !== root ||
|
|
1708
|
+
root.length <= 1 ||
|
|
1709
|
+
root.length > 4_096 ||
|
|
1710
|
+
/[\0\r\n]/.test(root)
|
|
1711
|
+
) {
|
|
1712
|
+
throw new Error("login temp root is invalid");
|
|
1713
|
+
}
|
|
1714
|
+
await mkdir(root, { recursive: true, mode: 0o700 });
|
|
1715
|
+
const entry = await lstat(root);
|
|
1716
|
+
const uid = typeof process.getuid === "function" ? process.getuid() : null;
|
|
1717
|
+
if (
|
|
1718
|
+
!entry.isDirectory() ||
|
|
1719
|
+
entry.isSymbolicLink() ||
|
|
1720
|
+
(uid !== null && entry.uid !== uid) ||
|
|
1721
|
+
(await realpath(root)) !== root
|
|
1722
|
+
) {
|
|
1723
|
+
throw new Error("login temp root is unsafe");
|
|
1724
|
+
}
|
|
1725
|
+
await chmod(root, 0o700);
|
|
1726
|
+
}
|
|
1727
|
+
|
|
1728
|
+
export interface EngineLoginTempReconcileOptions {
|
|
1729
|
+
readonly tempRoot?: string;
|
|
1730
|
+
readonly currentUid?: number | null;
|
|
1731
|
+
readonly readNames?: (root: string) => Promise<string[]>;
|
|
1732
|
+
readonly statEntry?: (path: string) => Promise<{
|
|
1733
|
+
isDirectory(): boolean;
|
|
1734
|
+
isSymbolicLink(): boolean;
|
|
1735
|
+
readonly uid: number;
|
|
1736
|
+
}>;
|
|
1737
|
+
readonly markerIsValid?: (
|
|
1738
|
+
path: string,
|
|
1739
|
+
name: string,
|
|
1740
|
+
currentUid: number | null,
|
|
1741
|
+
) => Promise<boolean>;
|
|
1742
|
+
readonly removeEntry?: (path: string) => Promise<void>;
|
|
1743
|
+
}
|
|
1744
|
+
|
|
1745
|
+
/**
|
|
1746
|
+
* Remove only direct, exact mkdtemp-shaped directories owned by this service
|
|
1747
|
+
* user. Symlinks, foreign-owned directories, and unrelated `/tmp` entries are
|
|
1748
|
+
* deliberately untouched. Callers must reconcile labeled containers first so
|
|
1749
|
+
* no still-live login process can own one of these mounts.
|
|
1750
|
+
*/
|
|
1751
|
+
export async function reconcileStaleEngineLoginTempDirs(
|
|
1752
|
+
options: EngineLoginTempReconcileOptions = {},
|
|
1753
|
+
): Promise<void> {
|
|
1754
|
+
const root = options.tempRoot ?? tmpdir();
|
|
1755
|
+
// Injected inventory seams own their synthetic root metadata. Production
|
|
1756
|
+
// reconciliation always uses the real reader and validates the exact leaf.
|
|
1757
|
+
if (options.tempRoot !== undefined && options.readNames === undefined) {
|
|
1758
|
+
await ensurePrivateEngineLoginTempRoot(root);
|
|
1759
|
+
}
|
|
1760
|
+
const names = await (options.readNames ?? ((path) => readdir(path)))(root);
|
|
1761
|
+
const candidates = names.filter((name) =>
|
|
1762
|
+
ENGINE_LOGIN_TEMP_DIR_PATTERN.test(name),
|
|
1763
|
+
);
|
|
1764
|
+
const uid =
|
|
1765
|
+
options.currentUid === undefined
|
|
1766
|
+
? typeof process.getuid === "function"
|
|
1767
|
+
? process.getuid()
|
|
1768
|
+
: null
|
|
1769
|
+
: options.currentUid;
|
|
1770
|
+
const statEntry = options.statEntry ?? lstat;
|
|
1771
|
+
const markerIsValid = options.markerIsValid ?? engineLoginTempMarkerIsValid;
|
|
1772
|
+
const removeEntry =
|
|
1773
|
+
options.removeEntry ?? ((path: string) => rm(path, { recursive: true, force: true }));
|
|
1774
|
+
|
|
1775
|
+
const owned: string[] = [];
|
|
1776
|
+
for (const name of candidates) {
|
|
1777
|
+
const path = join(root, name);
|
|
1778
|
+
let stat: Awaited<ReturnType<typeof statEntry>>;
|
|
1779
|
+
try {
|
|
1780
|
+
stat = await statEntry(path);
|
|
1781
|
+
} catch (error) {
|
|
1782
|
+
if (isNodeErrorCode(error, "ENOENT")) continue;
|
|
1783
|
+
throw error;
|
|
1784
|
+
}
|
|
1785
|
+
if (
|
|
1786
|
+
stat.isSymbolicLink() ||
|
|
1787
|
+
!stat.isDirectory() ||
|
|
1788
|
+
(uid !== null && stat.uid !== uid)
|
|
1789
|
+
) {
|
|
1790
|
+
continue;
|
|
1791
|
+
}
|
|
1792
|
+
if (!(await markerIsValid(path, name, uid))) continue;
|
|
1793
|
+
owned.push(path);
|
|
1794
|
+
}
|
|
1795
|
+
if (owned.length > MAX_STALE_LOGIN_TEMP_DIRS) {
|
|
1796
|
+
throw new Error("stale login temp inventory is invalid");
|
|
1797
|
+
}
|
|
1798
|
+
for (const path of owned) await removeEntry(path);
|
|
1799
|
+
}
|
|
1800
|
+
|
|
1801
|
+
async function engineLoginTempMarkerIsValid(
|
|
1802
|
+
path: string,
|
|
1803
|
+
name: string,
|
|
1804
|
+
currentUid: number | null,
|
|
1805
|
+
): Promise<boolean> {
|
|
1806
|
+
const markerPath = join(path, ENGINE_LOGIN_TEMP_MARKER);
|
|
1807
|
+
let before: Awaited<ReturnType<typeof lstat>>;
|
|
1808
|
+
try {
|
|
1809
|
+
before = await lstat(markerPath);
|
|
1810
|
+
} catch (error) {
|
|
1811
|
+
if (isNodeErrorCode(error, "ENOENT")) return false;
|
|
1812
|
+
throw error;
|
|
1813
|
+
}
|
|
1814
|
+
const expected = Buffer.from(engineLoginTempMarkerBody(name), "utf8");
|
|
1815
|
+
if (
|
|
1816
|
+
before.isSymbolicLink() ||
|
|
1817
|
+
!before.isFile() ||
|
|
1818
|
+
before.size !== expected.byteLength ||
|
|
1819
|
+
(before.mode & 0o777) !== 0o600 ||
|
|
1820
|
+
(currentUid !== null && before.uid !== currentUid)
|
|
1821
|
+
) {
|
|
1822
|
+
return false;
|
|
1823
|
+
}
|
|
1824
|
+
const noFollow = "O_NOFOLLOW" in constants ? constants.O_NOFOLLOW : 0;
|
|
1825
|
+
let handle: Awaited<ReturnType<typeof open>>;
|
|
1826
|
+
try {
|
|
1827
|
+
handle = await open(markerPath, constants.O_RDONLY | noFollow);
|
|
1828
|
+
} catch (error) {
|
|
1829
|
+
if (isNodeErrorCode(error, "ENOENT") || isNodeErrorCode(error, "ELOOP")) {
|
|
1830
|
+
return false;
|
|
1831
|
+
}
|
|
1832
|
+
throw error;
|
|
1833
|
+
}
|
|
1834
|
+
try {
|
|
1835
|
+
const opened = await handle.stat();
|
|
1836
|
+
if (
|
|
1837
|
+
!opened.isFile() ||
|
|
1838
|
+
opened.dev !== before.dev ||
|
|
1839
|
+
opened.ino !== before.ino ||
|
|
1840
|
+
opened.size !== expected.byteLength ||
|
|
1841
|
+
(opened.mode & 0o777) !== 0o600 ||
|
|
1842
|
+
(currentUid !== null && opened.uid !== currentUid)
|
|
1843
|
+
) {
|
|
1844
|
+
return false;
|
|
1845
|
+
}
|
|
1846
|
+
const body = await handle.readFile();
|
|
1847
|
+
return body.equals(expected);
|
|
1848
|
+
} finally {
|
|
1849
|
+
await handle.close();
|
|
1850
|
+
}
|
|
1851
|
+
}
|
|
1852
|
+
|
|
1853
|
+
/** Container absence is proven before any secret-bearing temp is removed. */
|
|
1854
|
+
export async function reconcileStaleEngineLoginResources(
|
|
1855
|
+
options: EngineLoginTempReconcileOptions = {},
|
|
1856
|
+
): Promise<void> {
|
|
1857
|
+
await reconcileStaleEngineLoginContainers();
|
|
1858
|
+
await reconcileStaleEngineLoginTempDirs(options);
|
|
1859
|
+
}
|
|
1860
|
+
|
|
1861
|
+
function isNodeErrorCode(error: unknown, code: string): boolean {
|
|
1862
|
+
return (
|
|
1863
|
+
error instanceof Error &&
|
|
1864
|
+
"code" in error &&
|
|
1865
|
+
(error as NodeJS.ErrnoException).code === code
|
|
1866
|
+
);
|
|
1867
|
+
}
|
|
1868
|
+
|
|
1869
|
+
async function readBoundedRegularFile(
|
|
1870
|
+
file: string,
|
|
1871
|
+
maxBytes: number,
|
|
1872
|
+
): Promise<Buffer> {
|
|
1873
|
+
const before = await lstat(file);
|
|
1874
|
+
if (!before.isFile() || before.isSymbolicLink() || before.size > maxBytes) {
|
|
1875
|
+
throw new Error("credential output is not a bounded regular file");
|
|
1876
|
+
}
|
|
1877
|
+
const noFollow = "O_NOFOLLOW" in constants ? constants.O_NOFOLLOW : 0;
|
|
1878
|
+
const handle = await open(file, constants.O_RDONLY | noFollow);
|
|
1879
|
+
try {
|
|
1880
|
+
const opened = await handle.stat();
|
|
1881
|
+
if (!opened.isFile() || opened.size < 1 || opened.size > maxBytes) {
|
|
1882
|
+
throw new Error("credential output is not a bounded regular file");
|
|
1883
|
+
}
|
|
1884
|
+
const body = await handle.readFile();
|
|
1885
|
+
if (body.byteLength < 1 || body.byteLength > maxBytes) {
|
|
1886
|
+
throw new Error("credential output exceeds its size limit");
|
|
1887
|
+
}
|
|
1888
|
+
return body;
|
|
1889
|
+
} finally {
|
|
1890
|
+
await handle.close();
|
|
1891
|
+
}
|
|
1892
|
+
}
|
|
1893
|
+
|
|
1894
|
+
async function writeSensitiveFileAtomic(file: string, body: Buffer): Promise<void> {
|
|
1895
|
+
const parent = dirname(file);
|
|
1896
|
+
await ensureSensitiveParent(parent);
|
|
1897
|
+
const temporary = join(
|
|
1898
|
+
parent,
|
|
1899
|
+
`.auth.json.${process.pid}.${randomBytes(8).toString("hex")}.tmp`,
|
|
1900
|
+
);
|
|
1901
|
+
let handle: Awaited<ReturnType<typeof open>> | null = null;
|
|
1902
|
+
try {
|
|
1903
|
+
handle = await open(temporary, "wx", 0o600);
|
|
1904
|
+
await handle.writeFile(body);
|
|
1905
|
+
await handle.sync();
|
|
1906
|
+
await handle.close();
|
|
1907
|
+
handle = null;
|
|
1908
|
+
await rename(temporary, file);
|
|
1909
|
+
await chmod(file, 0o600);
|
|
1910
|
+
await syncDirectory(parent);
|
|
1911
|
+
} finally {
|
|
1912
|
+
if (handle) await handle.close().catch(() => undefined);
|
|
1913
|
+
await unlink(temporary).catch((error: NodeJS.ErrnoException) => {
|
|
1914
|
+
if (error.code !== "ENOENT") throw error;
|
|
1915
|
+
});
|
|
1916
|
+
}
|
|
1917
|
+
}
|
|
1918
|
+
|
|
1919
|
+
async function ensureSensitiveParent(parent: string): Promise<void> {
|
|
1920
|
+
try {
|
|
1921
|
+
const current = await lstat(parent);
|
|
1922
|
+
if (!current.isDirectory() || current.isSymbolicLink()) {
|
|
1923
|
+
throw new Error("credential directory is not a local directory");
|
|
1924
|
+
}
|
|
1925
|
+
return;
|
|
1926
|
+
} catch (error) {
|
|
1927
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
1928
|
+
}
|
|
1929
|
+
|
|
1930
|
+
await mkdir(parent, { recursive: true, mode: 0o700 });
|
|
1931
|
+
// Re-check after creation. This catches an existing symlink followed by a
|
|
1932
|
+
// recursive mkdir and a same-user replacement racing the initial ENOENT.
|
|
1933
|
+
const created = await lstat(parent);
|
|
1934
|
+
if (!created.isDirectory() || created.isSymbolicLink()) {
|
|
1935
|
+
throw new Error("credential directory is not a local directory");
|
|
1936
|
+
}
|
|
1937
|
+
}
|
|
1938
|
+
|
|
1939
|
+
async function syncDirectory(directory: string): Promise<void> {
|
|
1940
|
+
let handle: Awaited<ReturnType<typeof open>> | null = null;
|
|
1941
|
+
try {
|
|
1942
|
+
handle = await open(directory, "r");
|
|
1943
|
+
await handle.sync();
|
|
1944
|
+
} catch (error) {
|
|
1945
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
1946
|
+
if (code !== "EINVAL" && code !== "ENOTSUP" && code !== "EISDIR") {
|
|
1947
|
+
throw error;
|
|
1948
|
+
}
|
|
1949
|
+
} finally {
|
|
1950
|
+
if (handle) await handle.close();
|
|
1951
|
+
}
|
|
1952
|
+
}
|