openmausbot 0.1.62 → 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.
- package/dist/assets/index-BOrNJcfG.js +273 -0
- package/dist/assets/{index-DTeYtYoC.js → index-BZeH50da.js} +1 -1
- package/dist/assets/index-Dwzk199I.css +1 -0
- package/dist/index.html +2 -2
- package/dist-server/container-mcp.js +3 -1
- package/dist-server/index.js +1049 -434
- package/dist-server/openmausbot.js +404 -168
- package/dist-server/pair-cli.js +404 -168
- package/dist-server/prepare-cloudflared.js +10 -2
- package/dist-server/server/attachments.js +15 -3
- package/dist-server/server/auto-approve.js +6 -20
- package/dist-server/server/config.js +5 -1
- package/dist-server/server/custom-domain.js +182 -0
- package/dist-server/server/drivers/codex-device-auth.js +234 -0
- package/dist-server/server/drivers/codex.js +10 -0
- package/dist-server/server/harness/registry.js +7 -0
- package/dist-server/server/index.js +109 -30
- package/dist-server/server/provider-auth-sessions.js +111 -0
- package/dist-server/server/sse-fanout.js +53 -0
- package/dist-server/src/lib/composer-attachments.js +20 -3
- package/dist-server/vps-container-mcp.js +3 -1
- package/package.json +1 -1
- package/dist/assets/index-BSI5e_fY.js +0 -264
- package/dist/assets/index-oPMv6a_k.css +0 -1
|
@@ -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
|
|
101
|
-
|
|
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
|
|
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);
|
|
@@ -11,27 +11,13 @@
|
|
|
11
11
|
// backstop for the obvious catastrophes. Real containment is the
|
|
12
12
|
// sandbox and the bot's own computer, not a regex.
|
|
13
13
|
import { approvalModeFor } from "../shared/approval-mode.js";
|
|
14
|
-
/**
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
* And Full is a decision the person made about THEIR OWN sessions with a
|
|
20
|
-
* bot — "run without asking me". A turn started by another bot is not one
|
|
21
|
-
* of those: the person never saw the request, and the sender may itself be
|
|
22
|
-
* unattended or working off a page it just read. A Full target reached that
|
|
23
|
-
* way runs as Approve for me instead — ordinary requests still flow, the
|
|
24
|
-
* destructive and sensitive guards card, an unattended sender's block holds,
|
|
25
|
-
* and every answer goes through the fold and into the decision log, where a
|
|
26
|
-
* driver-side Full accept never appears. A webhook or scheduled turn on a
|
|
27
|
-
* Full bot is unchanged: the person opted into that, and the docs say so. */
|
|
14
|
+
/** Full access is the person's explicit grant to this receiving bot, including
|
|
15
|
+
* delegated work. It never inherits the sender's mode or elevates another bot.
|
|
16
|
+
* Custom is a provider-config choice rather than an app Full-access grant, so
|
|
17
|
+
* peer-started Custom turns still use Auto and its unattended downgrade.
|
|
18
|
+
* Provider support and grant confirmation are checked by the caller. */
|
|
28
19
|
export function approvalModeForOrigin(mode, origin) {
|
|
29
|
-
|
|
30
|
-
// this only answers who STARTED the turn. A permissive mode is the
|
|
31
|
-
// person's grant to the bot they talk to, not to every teammate that can
|
|
32
|
-
// reach it, so a peer-started turn runs one notch down and still gets the
|
|
33
|
-
// unattended downgrade after this.
|
|
34
|
-
if ((mode === "full" || mode === "custom") && origin.peerInitiated)
|
|
20
|
+
if (mode === "custom" && origin.peerInitiated)
|
|
35
21
|
return "auto";
|
|
36
22
|
return mode;
|
|
37
23
|
}
|
|
@@ -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 —
|