openmausbot 0.1.63 → 0.1.65

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.
@@ -35,6 +35,12 @@ var CLOUDFLARED_ASSETS = Object.freeze({
35
35
  binarySha256: "fcfb02b575a52ca1af2e3267af4e1517bcdeb30ac48c834c69abaed3c0576ad2",
36
36
  archive: false
37
37
  }),
38
+ "linux-arm64": Object.freeze({
39
+ name: "cloudflared-linux-arm64",
40
+ sha256: "7747d94570fb390cf47dcb4f9555c193c6355cda9793f0d878d9049e5d6a7790",
41
+ binarySha256: "7747d94570fb390cf47dcb4f9555c193c6355cda9793f0d878d9049e5d6a7790",
42
+ archive: false
43
+ }),
38
44
  "win32-x64": Object.freeze({
39
45
  name: "cloudflared-windows-amd64.exe",
40
46
  sha256: "c29eee2b121f5436a642eed69fd9767da7e7b8c510fa50aaa130337f931357b5",
@@ -97,8 +103,10 @@ function executableTarget(value) {
97
103
  if (cpu === 16777223) return "darwin-x64";
98
104
  throw new Error(`unsupported cloudflared Mach-O CPU type 0x${cpu.toString(16)}`);
99
105
  }
100
- if (bytes.length >= 20 && bytes.subarray(0, 4).equals(Buffer.from([127, 69, 76, 70])) && bytes[4] === 2 && bytes[5] === 1 && bytes.readUInt16LE(18) === 62) {
101
- return "linux-x64";
106
+ if (bytes.length >= 20 && bytes.subarray(0, 4).equals(Buffer.from([127, 69, 76, 70])) && bytes[4] === 2 && bytes[5] === 1) {
107
+ const machine = bytes.readUInt16LE(18);
108
+ if (machine === 62) return "linux-x64";
109
+ if (machine === 183) return "linux-arm64";
102
110
  }
103
111
  if (bytes.length >= 64 && bytes[0] === 77 && bytes[1] === 90) {
104
112
  const pe = bytes.readUInt32LE(60);
@@ -35,12 +35,24 @@ const IMAGE_MIMES = {
35
35
  "image/gif": ".gif",
36
36
  "image/webp": ".webp",
37
37
  };
38
- /** Useful document formats accepted by the phone share sheet.
38
+ /** Useful document and audio formats accepted by the upload endpoint.
39
39
  * Generic archives, binaries, HTML, SVG, and executable/script mimes stay
40
40
  * out. Office/OpenDocument packages are allowed because they are documents,
41
41
  * despite using ZIP internally. The claimed mime determines the extension;
42
42
  * an attacker-controlled filename never does. */
43
43
  const FILE_MIMES = {
44
+ "audio/opus": ".opus",
45
+ "audio/ogg": ".ogg",
46
+ "audio/mpeg": ".mp3",
47
+ "audio/mp4": ".m4a",
48
+ "audio/x-m4a": ".m4a",
49
+ "audio/aac": ".aac",
50
+ "audio/wav": ".wav",
51
+ "audio/x-wav": ".wav",
52
+ "audio/wave": ".wav",
53
+ "audio/flac": ".flac",
54
+ "audio/x-flac": ".flac",
55
+ "audio/webm": ".webm",
44
56
  "text/plain": ".txt",
45
57
  "text/markdown": ".md",
46
58
  "text/csv": ".csv",
@@ -225,7 +237,7 @@ export function extensionForFileMime(mime) {
225
237
  export function sanitizeSharedFileName(name, mime) {
226
238
  const extension = extensionForFileMime(mime);
227
239
  if (!extension)
228
- throw statusError(400, "content-type must be a supported document type");
240
+ throw statusError(400, "content-type must be a supported document or audio type");
229
241
  const normalized = name.normalize("NFKC").trim();
230
242
  if (!normalized)
231
243
  throw statusError(400, "name is required");
@@ -256,7 +268,7 @@ export function sanitizeSharedFileName(name, mime) {
256
268
  export async function saveFile(chunks, originalName, mime, options = {}) {
257
269
  const normalized = normalizedMime(mime);
258
270
  if (!normalized || !extensionForFileMime(normalized)) {
259
- throw statusError(400, "content-type must be a supported document type");
271
+ throw statusError(400, "content-type must be a supported document or audio type");
260
272
  }
261
273
  const name = sanitizeSharedFileName(originalName, normalized);
262
274
  const extension = extensionForFileMime(normalized);
@@ -216,6 +216,8 @@ const defaultModelSelectionSchema = z.object({
216
216
  effort: z.enum(EFFORT_LEVELS).optional(),
217
217
  });
218
218
  const appConfigSchema = z.object({
219
+ /** Verified by the dedicated domain endpoint, never a generic config patch. */
220
+ customDomain: z.string().optional(),
219
221
  defaultModelSelection: defaultModelSelectionSchema.optional(),
220
222
  /** CLI-only launch preferences. Never enable remote access implicitly. */
221
223
  cliStartup: z.object({
@@ -261,7 +263,7 @@ const appConfigSchema = z.object({
261
263
  const storedAppConfigSchema = appConfigSchema.extend({
262
264
  browserProfiles: storedBrowserProfilesSchema.optional(),
263
265
  });
264
- const appConfigPatchSchema = appConfigSchema.omit({ instances: true, mcpServers: true, cliStartup: true });
266
+ const appConfigPatchSchema = appConfigSchema.omit({ instances: true, mcpServers: true, cliStartup: true, customDomain: true });
265
267
  const jsonObjectSchema = z.record(z.string(), z.json());
266
268
  /** Resolve a canonical profile record to its exact durable Electron
267
269
  * partition identity. Callers must never substitute the display/API id. */
@@ -543,6 +545,8 @@ export function saveConfig(patch) {
543
545
  // scalar, not a section: the merge loop above only walks objects
544
546
  if (checkedPatch.language !== undefined)
545
547
  disk.language = checkedPatch.language;
548
+ if (checkedPatch.customDomain !== undefined)
549
+ disk.customDomain = checkedPatch.customDomain;
546
550
  // A selection is replaced as one value, so changing engines also clears
547
551
  // an effort level omitted from the new selection.
548
552
  if (checkedPatch.defaultModelSelection !== undefined) {
@@ -0,0 +1,182 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { lookup as dnsLookup } from "node:dns/promises";
3
+ import { request as httpsRequest } from "node:https";
4
+ import { BlockList, isIP } from "node:net";
5
+ const CHECK_TIMEOUT_MS = 8_000;
6
+ const MAX_RESPONSE_BYTES = 16_384;
7
+ const DESCRIPTOR_PATH = "/.well-known/openmausbot/environment";
8
+ export const CUSTOM_DOMAIN_CHALLENGE_PATH = "/.well-known/openmausbot/domain-check/";
9
+ export class CustomDomainError extends Error {
10
+ status;
11
+ constructor(message, status = 400) {
12
+ super(message);
13
+ this.name = "CustomDomainError";
14
+ this.status = status;
15
+ }
16
+ }
17
+ /** A public domain on standard HTTPS, never an invitation or arbitrary URL. */
18
+ export function normalizeCustomDomain(raw) {
19
+ const value = raw.trim();
20
+ // Reject characters URL parsing otherwise removes or interprets as secrets.
21
+ // eslint-disable-next-line no-control-regex
22
+ if (!value || value.length > 512 || /[\s\u0000-\u001f\u007f\\?#@%]/.test(value)) {
23
+ throw new CustomDomainError("Enter a domain such as bots.yourcompany.com, without a path, password, or pairing code.");
24
+ }
25
+ try {
26
+ const url = new URL(value.includes("://") ? value : `https://${value}`);
27
+ const host = url.hostname.toLowerCase().replace(/\.$/, "");
28
+ if (url.protocol !== "https:" || url.port || url.pathname !== "/" || url.username || url.password
29
+ || isIP(host) || host.startsWith("[") || host.length > 253 || !host.includes(".")
30
+ || !host.split(".").every((part) => /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(part))
31
+ || /(?:^|\.)(?:localhost|local|internal|invalid|test)$/.test(host))
32
+ throw new Error("invalid origin");
33
+ return `https://${host}`;
34
+ }
35
+ catch {
36
+ throw new CustomDomainError("Use a public domain with HTTPS on port 443, without a path or pairing code.");
37
+ }
38
+ }
39
+ const excludedAddresses = new BlockList();
40
+ for (const [address, prefix] of [
41
+ ["0.0.0.0", 8], ["10.0.0.0", 8], ["100.64.0.0", 10], ["127.0.0.0", 8],
42
+ ["169.254.0.0", 16], ["172.16.0.0", 12], ["192.0.0.0", 24], ["192.0.2.0", 24],
43
+ ["192.88.99.0", 24], ["192.168.0.0", 16], ["198.18.0.0", 15], ["198.51.100.0", 24],
44
+ ["203.0.113.0", 24], ["224.0.0.0", 4], ["240.0.0.0", 4],
45
+ ])
46
+ excludedAddresses.addSubnet(address, prefix, "ipv4");
47
+ for (const [address, prefix] of [
48
+ ["2001::", 23], ["2001:db8::", 32], ["2002::", 16], ["3fff::", 20],
49
+ ])
50
+ excludedAddresses.addSubnet(address, prefix, "ipv6");
51
+ const globalIpv6 = new BlockList();
52
+ globalIpv6.addSubnet("2000::", 3, "ipv6");
53
+ /** Exclude private, mapped/NAT64, multicast, documentation, and reserved ranges. */
54
+ export function isPublicDomainAddress(address) {
55
+ const family = isIP(address);
56
+ if (family === 4)
57
+ return !excludedAddresses.check(address, "ipv4");
58
+ return family === 6 && globalIpv6.check(address, "ipv6") && !excludedAddresses.check(address, "ipv6");
59
+ }
60
+ function withAbort(promise, signal) {
61
+ return new Promise((resolve, reject) => {
62
+ const abort = () => reject(new CustomDomainError("The domain check timed out. Check DNS, HTTPS, and your reverse proxy, then try again."));
63
+ if (signal.aborted) {
64
+ abort();
65
+ return;
66
+ }
67
+ signal.addEventListener("abort", abort, { once: true });
68
+ promise.then(resolve, reject).finally(() => signal.removeEventListener("abort", abort));
69
+ });
70
+ }
71
+ function requestJson(url, address, signal) {
72
+ return new Promise((resolve, reject) => {
73
+ // Pin the validated DNS result for both requests. TLS/SNI and Host still use
74
+ // the original domain, so a DNS rebind cannot reach a private IP afterwards.
75
+ const lookup = (_hostname, options, callback) => {
76
+ if (options.all)
77
+ callback(null, [address]);
78
+ else
79
+ callback(null, address.address, address.family);
80
+ };
81
+ const request = httpsRequest(url, {
82
+ method: "GET", agent: false, lookup, signal, rejectUnauthorized: true,
83
+ headers: { Accept: "application/json", "User-Agent": "OpenMausBot-domain-check" },
84
+ }, (response) => {
85
+ // Node's HTTPS client does not follow Location redirects.
86
+ if (response.statusCode !== 200) {
87
+ response.destroy();
88
+ reject(new CustomDomainError("The domain did not return the OpenMausBot verification page. Check your reverse proxy; redirects are not supported."));
89
+ return;
90
+ }
91
+ const chunks = [];
92
+ let size = 0;
93
+ response.on("error", reject);
94
+ response.on("data", (chunk) => {
95
+ size += chunk.length;
96
+ if (size > MAX_RESPONSE_BYTES) {
97
+ response.destroy();
98
+ reject(new CustomDomainError("The domain returned an unexpected response. Check that it points to OpenMausBot."));
99
+ return;
100
+ }
101
+ chunks.push(chunk);
102
+ });
103
+ response.on("end", () => {
104
+ try {
105
+ resolve(JSON.parse(Buffer.concat(chunks).toString("utf8")));
106
+ }
107
+ catch {
108
+ reject(new CustomDomainError("The domain did not return OpenMausBot data. Check that it points to this server."));
109
+ }
110
+ });
111
+ });
112
+ request.on("error", reject);
113
+ request.end();
114
+ });
115
+ }
116
+ /** Verification only: callers persist the returned origin after success. The
117
+ * public challenge endpoint reveals no account credentials and grants no access. */
118
+ export function createCustomDomainVerifier(dependencies) {
119
+ const lookup = dependencies.lookup ?? ((hostname) => dnsLookup(hostname, { all: true, verbatim: true }));
120
+ const request = dependencies.request ?? requestJson;
121
+ let inFlight = false;
122
+ let active = null;
123
+ return {
124
+ challenge(token) {
125
+ if (!active || Date.now() >= active.expiresAt || token !== active.token)
126
+ return null;
127
+ return { environmentId: dependencies.environmentId, proof: active.proof };
128
+ },
129
+ async verify(raw) {
130
+ const origin = normalizeCustomDomain(raw);
131
+ if (inFlight)
132
+ throw new CustomDomainError("A domain check is already running. Wait for it to finish, then try again.", 409);
133
+ inFlight = true;
134
+ const controller = new AbortController();
135
+ const timeoutMs = dependencies.timeoutMs ?? CHECK_TIMEOUT_MS;
136
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
137
+ try {
138
+ let addresses;
139
+ try {
140
+ addresses = await withAbort(lookup(new URL(origin).hostname), controller.signal);
141
+ }
142
+ catch (error) {
143
+ if (error instanceof CustomDomainError)
144
+ throw error;
145
+ throw new CustomDomainError("The domain could not be found in DNS. Add its A or AAAA record and allow time for it to become available.");
146
+ }
147
+ if (!addresses.length || addresses.some((entry) => !isPublicDomainAddress(entry.address)
148
+ || isIP(entry.address) !== entry.family)) {
149
+ throw new CustomDomainError("The domain must resolve only to public internet addresses. Local, private, and reserved addresses cannot be connected here.");
150
+ }
151
+ // Prefer IPv4 where both exist; use the exact same address for proof.
152
+ const address = addresses.find((entry) => entry.family === 4) ?? addresses[0];
153
+ const descriptor = await withAbort(request(new URL(DESCRIPTOR_PATH, origin), address, controller.signal), controller.signal);
154
+ if (!descriptor || typeof descriptor !== "object"
155
+ || Reflect.get(descriptor, "environmentId") !== dependencies.environmentId) {
156
+ throw new CustomDomainError("This domain does not point to this OpenMausBot server. Check its DNS and reverse proxy configuration.");
157
+ }
158
+ active = {
159
+ token: randomBytes(32).toString("hex"), proof: randomBytes(32).toString("hex"),
160
+ expiresAt: Date.now() + timeoutMs,
161
+ };
162
+ const proof = await withAbort(request(new URL(`${CUSTOM_DOMAIN_CHALLENGE_PATH}${active.token}`, origin), address, controller.signal), controller.signal);
163
+ if (!proof || typeof proof !== "object" || Reflect.get(proof, "environmentId") !== dependencies.environmentId
164
+ || Reflect.get(proof, "proof") !== active.proof) {
165
+ throw new CustomDomainError("The domain could not prove it reaches this server. Forward all OpenMausBot routes through your reverse proxy and try again.");
166
+ }
167
+ return { origin, verifiedAt: new Date().toISOString() };
168
+ }
169
+ catch (error) {
170
+ if (error instanceof CustomDomainError)
171
+ throw error;
172
+ throw new CustomDomainError("A secure connection to the domain could not be verified. Check its HTTPS certificate, firewall, and reverse proxy.");
173
+ }
174
+ finally {
175
+ clearTimeout(timer);
176
+ controller.abort();
177
+ active = null;
178
+ inFlight = false;
179
+ }
180
+ },
181
+ };
182
+ }
@@ -0,0 +1,234 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { realpathSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { basename, dirname, isAbsolute, join, resolve } from "node:path";
5
+ import { stripVTControlCharacters } from "node:util";
6
+ import { killCliTree, spawnCli } from "../procs.js";
7
+ const DEVICE_URL = "https://auth.openai.com/codex/device";
8
+ const MAX_OUTPUT = 16_384;
9
+ // Two configured instances can use the same on-disk login. Only the auth
10
+ // operation is exclusive: normal turns are not blocked by this lock.
11
+ const authenticatingHomes = new Set();
12
+ function canonicalPath(path) {
13
+ try {
14
+ return realpathSync(path);
15
+ }
16
+ catch {
17
+ const parent = dirname(path);
18
+ return parent === path ? path : join(canonicalPath(parent), basename(path));
19
+ }
20
+ }
21
+ export function codexDevicePrompt(output) {
22
+ const clean = stripVTControlCharacters(output);
23
+ // Chunks may stop halfway through a code. Never publish the incomplete
24
+ // final line before its newline arrives.
25
+ const lines = clean.split(/\r?\n/).slice(0, -1).map((line) => line.trim());
26
+ // Do not turn arbitrary output into a link, or pass through query tokens.
27
+ if (!lines.some((line) => /^https:\/\/auth\.openai\.com\/codex\/device\/?$/.test(line)))
28
+ return null;
29
+ const userCode = lines.find((line) => /^[A-Z0-9]{4,8}-[A-Z0-9]{4,8}$/.test(line));
30
+ return userCode ? { authorizationUrl: DEVICE_URL, userCode } : null;
31
+ }
32
+ function loginFailure(output) {
33
+ if (/(unexpected argument|unrecognized (argument|option)|unknown option).*device-auth/i.test(output)) {
34
+ return "This server's Codex CLI needs updating for browser sign-in. Run npm install -g @openai/codex@latest on the server, then try again.";
35
+ }
36
+ if (/device.{0,40}(disabled|not enabled|not allowed)|enable.{0,40}device/i.test(output)) {
37
+ return "Enable device-code login in your ChatGPT security settings or ask your workspace admin to allow it, then try again.";
38
+ }
39
+ if (/expired|expiration|timed out/i.test(output)) {
40
+ return "The ChatGPT sign-in code expired. Start sign-in again for a new code.";
41
+ }
42
+ return "ChatGPT sign-in did not finish. Check the server's connection and that device-code login is enabled in ChatGPT, then try again.";
43
+ }
44
+ /** A fixed Codex login command, not a remotely accessible terminal. CLI
45
+ * output stays in bounded private memory; only a device URL/code is exposed. */
46
+ export class CodexDeviceAuthController {
47
+ flow = null;
48
+ disposed = false;
49
+ options;
50
+ constructor(options) { this.options = options; }
51
+ async start() {
52
+ if (this.disposed)
53
+ throw new Error("This provider was removed. Refresh Settings before signing in.");
54
+ if (this.flow?.status.phase === "waiting") {
55
+ if (this.flow.ready)
56
+ return { ...this.flow.status, phase: "waiting" };
57
+ throw new Error("A ChatGPT sign-in is already starting. Please wait for the code.");
58
+ }
59
+ await this.flow?.stopping;
60
+ if (this.disposed)
61
+ throw new Error("This provider was removed. Refresh Settings before signing in.");
62
+ const env = this.options.environment();
63
+ const home = env.HOME || env.USERPROFILE || homedir();
64
+ if (!isAbsolute(home) || (env.CODEX_HOME && !isAbsolute(env.CODEX_HOME))) {
65
+ throw new Error("Use an absolute HOME and CODEX_HOME path for this server's Codex provider before signing in.");
66
+ }
67
+ const homeKey = canonicalPath(resolve(env.CODEX_HOME || join(home, ".codex")));
68
+ if (authenticatingHomes.has(homeKey)) {
69
+ throw new Error("A ChatGPT sign-in is already running for this server account. Finish or cancel it before starting another.");
70
+ }
71
+ authenticatingHomes.add(homeKey);
72
+ return new Promise((resolveStart, reject) => {
73
+ const flow = {
74
+ status: {
75
+ phase: "waiting", flowId: randomUUID(), authorizationUrl: null,
76
+ expiresAt: new Date(Date.now() + (this.options.lifetimeMs ?? 15 * 60_000)).toISOString(),
77
+ },
78
+ child: null, ready: false, resolve: resolveStart, reject, homeKey,
79
+ startupTimer: setTimeout(() => this.finish(flow, "failed", "Codex did not provide a sign-in code in time. Check the server connection and update Codex, then try again."), this.options.startupTimeoutMs ?? 30_000),
80
+ expiryTimer: setTimeout(() => this.finish(flow, "expired", "The ChatGPT sign-in code expired. Start sign-in again."), this.options.lifetimeMs ?? 15 * 60_000),
81
+ };
82
+ flow.startupTimer.unref();
83
+ flow.expiryTimer.unref();
84
+ this.flow = flow;
85
+ // Never overwrite a working login just because Connect was clicked twice.
86
+ this.run(flow, ["login", "status"], env, home, (code, output) => {
87
+ if (code === 0 && /^logged in.*chatgpt\b/im.test(output)) {
88
+ this.finish(flow, "succeeded");
89
+ }
90
+ else if (code === 0 && /^logged in\b/im.test(output)) {
91
+ this.finish(flow, "failed", "Codex already has a different sign-in method on this server. Ask the server administrator to review it before changing accounts.");
92
+ }
93
+ else if (code !== 0 && /^not logged in\b/im.test(output)) {
94
+ this.run(flow, ["login", "--device-auth"], env, home, (loginCode, loginOutput) => {
95
+ if (loginCode !== 0 || !flow.ready) {
96
+ this.finish(flow, "failed", loginFailure(loginOutput));
97
+ return;
98
+ }
99
+ // A successful command is not enough: verify credentials using the
100
+ // same executable and environment that will run this client's bots.
101
+ flow.startupTimer = setTimeout(() => this.finish(flow, "failed", "Codex finished sign-in but could not confirm the account. Refresh Settings and try again."), this.options.startupTimeoutMs ?? 30_000);
102
+ flow.startupTimer.unref();
103
+ this.run(flow, ["login", "status"], env, home, (statusCode, statusOutput) => {
104
+ this.finish(flow, statusCode === 0 && /^logged in.*chatgpt\b/im.test(statusOutput) ? "succeeded" : "failed", "Codex finished sign-in but did not confirm a ChatGPT account. Refresh Settings and try again.");
105
+ });
106
+ }, true);
107
+ }
108
+ else
109
+ this.finish(flow, "failed", "Codex could not confirm the existing sign-in on this server. Update Codex and check its login status before trying again.");
110
+ });
111
+ });
112
+ }
113
+ async get(flowId) {
114
+ if (!flowId || this.flow?.status.flowId !== flowId)
115
+ throw new Error("This sign-in is no longer available. Start sign-in again.");
116
+ return { ...this.flow.status };
117
+ }
118
+ async cancel() {
119
+ if (this.flow?.status.phase === "waiting")
120
+ this.finish(this.flow, "cancelled", "ChatGPT sign-in cancelled.");
121
+ await this.flow?.stopping;
122
+ if (this.flow?.terminationFailed)
123
+ throw new Error(this.flow.status.message);
124
+ }
125
+ async dispose() {
126
+ this.disposed = true;
127
+ await this.cancel();
128
+ this.flow = null;
129
+ }
130
+ run(flow, args, env, cwd, done, parsePrompt = false) {
131
+ if (flow.status.phase !== "waiting")
132
+ return;
133
+ let child;
134
+ try {
135
+ child = spawnCli(this.options.cli, args, { env: { ...env, NO_COLOR: "1" }, cwd, stdio: ["pipe", "pipe", "pipe"] });
136
+ }
137
+ catch {
138
+ this.finish(flow, "failed", "Codex could not start on this server. Install or update the configured Codex CLI, then try again.");
139
+ return;
140
+ }
141
+ flow.child = child;
142
+ child.stdin.end();
143
+ let output = "";
144
+ const receive = (chunk) => {
145
+ if (flow.status.phase !== "waiting")
146
+ return;
147
+ if (output.length + chunk.length > MAX_OUTPUT) {
148
+ this.finish(flow, "failed", "Codex returned an unexpected sign-in response. Update the server's Codex CLI and try again.");
149
+ return;
150
+ }
151
+ output += chunk.toString("utf8");
152
+ const prompt = parsePrompt && !flow.ready ? codexDevicePrompt(output) : null;
153
+ if (prompt) {
154
+ flow.status = { ...flow.status, ...prompt };
155
+ flow.ready = true;
156
+ clearTimeout(flow.startupTimer);
157
+ flow.resolve({ ...flow.status, phase: "waiting" });
158
+ }
159
+ };
160
+ child.stdout.on("data", receive);
161
+ child.stderr.on("data", receive);
162
+ child.once("error", (error) => {
163
+ const message = error.code === "ENOENT"
164
+ ? "Codex is not installed on this server. Run npm install -g @openai/codex@latest on the server, then try again."
165
+ : "Codex could not start on this server. Check the configured CLI path and its executable permissions.";
166
+ this.finish(flow, "failed", message);
167
+ });
168
+ child.once("close", (code) => {
169
+ if (flow.child === child)
170
+ flow.child = null;
171
+ if (flow.status.phase === "waiting")
172
+ done(code, stripVTControlCharacters(output));
173
+ output = "";
174
+ });
175
+ }
176
+ finish(flow, phase, message) {
177
+ if (flow.status.phase !== "waiting")
178
+ return;
179
+ clearTimeout(flow.startupTimer);
180
+ clearTimeout(flow.expiryTimer);
181
+ flow.status = {
182
+ phase, flowId: flow.status.flowId, authorizationUrl: null, expiresAt: null,
183
+ ...(phase !== "succeeded" && message ? { message } : {}),
184
+ };
185
+ if (!flow.ready) {
186
+ if (phase === "succeeded")
187
+ flow.resolve({ ...flow.status, phase });
188
+ else
189
+ flow.reject(new Error(message ?? "ChatGPT sign-in did not finish."));
190
+ }
191
+ flow.stopping = this.stopChild(flow).catch(() => false).then((stopped) => {
192
+ const release = () => authenticatingHomes.delete(flow.homeKey);
193
+ if (stopped) {
194
+ release();
195
+ return;
196
+ }
197
+ flow.terminationFailed = true;
198
+ flow.status = { ...flow.status, phase: "failed", message: "Codex could not be stopped on this server. Ask the server administrator to stop the login process before trying again." };
199
+ // Retain the credential-home lock until the OS actually reports exit.
200
+ const child = flow.child;
201
+ if (!child || child.exitCode !== null || child.signalCode !== null)
202
+ release();
203
+ else
204
+ child.once("close", release);
205
+ });
206
+ if (phase === "succeeded")
207
+ void this.options.onAuthenticated?.().catch(() => { });
208
+ }
209
+ async stopChild(flow) {
210
+ const child = flow.child;
211
+ if (!child || await killCliTree(child, this.options.terminateTimeoutMs ?? 1500))
212
+ return true;
213
+ // A broken CLI may ignore SIGTERM. Do not leave a stale device flow able
214
+ // to write credentials after the user cancelled it.
215
+ if (process.platform !== "win32" && child.pid) {
216
+ try {
217
+ process.kill(-child.pid, "SIGKILL");
218
+ }
219
+ catch {
220
+ child.kill("SIGKILL");
221
+ }
222
+ }
223
+ else
224
+ child.kill("SIGKILL");
225
+ await new Promise((resolveStop) => {
226
+ if (child.exitCode !== null || child.signalCode !== null)
227
+ return resolveStop();
228
+ const timer = setTimeout(resolveStop, 1500);
229
+ timer.unref();
230
+ child.once("close", () => { clearTimeout(timer); resolveStop(); });
231
+ });
232
+ return child.exitCode !== null || child.signalCode !== null;
233
+ }
234
+ }
@@ -23,6 +23,7 @@ import { augmentedPath, splitCliString } from "../env-path.js";
23
23
  import { classifyError, computeBackoff, RETRY_MAX_ATTEMPTS } from "./retry.js";
24
24
  import { appendNative } from "./native.js";
25
25
  import { codexDeveloperInstructions, syncCodexInstructions } from "./codex-instructions.js";
26
+ import { CodexDeviceAuthController } from "./codex-device-auth.js";
26
27
  export { decodeCodexSelection, readCodexModelCatalog, STATIC_CODEX_MODELS } from "./codex-catalog.js";
27
28
  const DRIVER_KIND = "codex";
28
29
  const ASTRA_MODEL_ID = "gpt-6-astra";
@@ -424,6 +425,11 @@ export const CodexDriver = {
424
425
  }
425
426
  };
426
427
  await refreshModels();
428
+ const authentication = new CodexDeviceAuthController({
429
+ cli: config.cli,
430
+ environment: childEnv,
431
+ onAuthenticated: refreshModels,
432
+ });
427
433
  const listeners = new Set();
428
434
  const active = new Map();
429
435
  const emit = (event) => {
@@ -1105,6 +1111,9 @@ export const CodexDriver = {
1105
1111
  return models;
1106
1112
  },
1107
1113
  refreshModels,
1114
+ startAuthentication: () => authentication.start(),
1115
+ getAuthentication: (flowId) => authentication.get(flowId),
1116
+ cancelAuthentication: () => authentication.cancel(),
1108
1117
  snapshot,
1109
1118
  adapter: {
1110
1119
  provider: DRIVER_KIND,
@@ -1143,6 +1152,7 @@ export const CodexDriver = {
1143
1152
  },
1144
1153
  },
1145
1154
  dispose: async () => {
1155
+ await authentication.dispose();
1146
1156
  await Promise.all([...active.values()].map(({ stop }) => stop()));
1147
1157
  listeners.clear();
1148
1158
  },
@@ -122,6 +122,10 @@ export class ProviderRegistry {
122
122
  const instance = this.get(instanceId);
123
123
  return instance?.startAuthentication ? instance.startAuthentication() : null;
124
124
  }
125
+ async getAuthentication(instanceId, flowId) {
126
+ const instance = this.get(instanceId);
127
+ return instance?.getAuthentication ? instance.getAuthentication(flowId) : null;
128
+ }
125
129
  async completeAuthentication(instanceId, flowId, callbackUrl) {
126
130
  const instance = this.get(instanceId);
127
131
  if (!instance?.completeAuthentication)
@@ -200,6 +204,9 @@ export class ProviderRegistry {
200
204
  },
201
205
  access: driver?.metadata.access ?? "subscription",
202
206
  install: driver?.install,
207
+ authentication: inst.startAuthentication
208
+ ? { method: inst.getAuthentication ? "device-code" : "browser" }
209
+ : undefined,
203
210
  cli: this.cliByInstance.get(inst.instanceId),
204
211
  cliDefault: cliDefaultOf(driver),
205
212
  // every copy of the driver's default binary on the augmented PATH —