openmausbot 0.1.67 → 0.1.69
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-BwpNqgCm.js → index-BmHupgVJ.js} +1 -1
- package/dist/assets/index-COp5lMnv.js +279 -0
- package/dist/assets/index-Jk7TIeyW.css +1 -0
- package/dist/index.html +2 -2
- package/dist-server/container-mcp.js +40 -2
- package/dist-server/drivers/agents-proxy.js +112 -7
- package/dist-server/enterprise/server/index.js +14587 -0
- package/dist-server/index.js +1732 -681
- package/dist-server/openmausbot.js +939 -381
- package/dist-server/pair-cli.js +939 -381
- package/dist-server/server/avatar-image.js +86 -34
- package/dist-server/server/bot-package.js +60 -14
- package/dist-server/server/brand.js +7 -0
- package/dist-server/server/cli.js +23 -1
- package/dist-server/server/config.js +30 -3
- package/dist-server/server/delegations.js +56 -11
- package/dist-server/server/drivers/acp/qwen.js +142 -5
- package/dist-server/server/drivers/agents-proxy.js +135 -8
- package/dist-server/server/drivers/codex-device-auth.js +102 -3
- package/dist-server/server/drivers/codex-identity.js +115 -0
- package/dist-server/server/drivers/codex.js +6 -0
- package/dist-server/server/harness/registry.js +2 -0
- package/dist-server/server/index.js +220 -65
- package/dist-server/server/package-export.js +7 -0
- package/dist-server/server/provider-auth-sessions.js +24 -0
- package/dist-server/server/routine-requests.js +270 -25
- package/dist-server/server/routines.js +215 -21
- package/dist-server/server/service-cli.js +63 -0
- package/dist-server/server/service-unit.js +129 -0
- package/dist-server/server/steer-queue.js +30 -1
- package/dist-server/server/store.js +30 -4
- package/dist-server/server/system-prompt.js +1 -0
- package/dist-server/server/turn-dispatch-guard.js +6 -0
- package/dist-server/shared/image-generation.js +28 -0
- package/dist-server/vps-container-mcp.js +40 -2
- package/enterprise/LICENSE +32 -0
- package/enterprise/README.md +89 -0
- package/enterprise/server/index.js +14587 -0
- package/package.json +2 -1
- package/dist/assets/index-A3CBTxwl.css +0 -1
- package/dist/assets/index-CDuyoVPJ.js +0 -278
|
@@ -2,13 +2,109 @@
|
|
|
2
2
|
// the official pane has no Qwen Cloud catalog; live local hosts land in
|
|
3
3
|
// Custom and are written into ~/.qwen/settings.json modelProviders.
|
|
4
4
|
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { createHash } from "node:crypto";
|
|
5
6
|
import { homedir } from "node:os";
|
|
6
7
|
import { join } from "node:path";
|
|
7
8
|
import { decodeInjectId, hostApiKey, localHost, mergeLocalInject } from "../local-inject.js";
|
|
8
9
|
import { createAcpDriver } from "./core.js";
|
|
9
10
|
const EMPTY = { default: "", options: [] };
|
|
10
11
|
function qwenHome(env) {
|
|
11
|
-
|
|
12
|
+
const home = process.platform === "win32"
|
|
13
|
+
? env.USERPROFILE || env.HOME || homedir()
|
|
14
|
+
: env.HOME || env.USERPROFILE || homedir();
|
|
15
|
+
return join(home, ".qwen");
|
|
16
|
+
}
|
|
17
|
+
function isRecord(value) {
|
|
18
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
19
|
+
}
|
|
20
|
+
function qwenModelLabel(id, name) {
|
|
21
|
+
if (!name || name.toLocaleLowerCase().endsWith(id.toLocaleLowerCase()))
|
|
22
|
+
return id;
|
|
23
|
+
return `${id} — ${name}`;
|
|
24
|
+
}
|
|
25
|
+
const PROTOCOLS = new Set(["openai", "anthropic", "gemini", "vertex-ai"]);
|
|
26
|
+
function readQwenRoutes(env) {
|
|
27
|
+
let settings;
|
|
28
|
+
try {
|
|
29
|
+
settings = JSON.parse(readFileSync(join(qwenHome(env), "settings.json"), "utf8"));
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return [];
|
|
33
|
+
}
|
|
34
|
+
if (!isRecord(settings) || !isRecord(settings.modelProviders))
|
|
35
|
+
return [];
|
|
36
|
+
const routes = [];
|
|
37
|
+
const seen = new Set();
|
|
38
|
+
const mappings = isRecord(settings.providerProtocol) ? settings.providerProtocol : {};
|
|
39
|
+
for (const [provider, rows] of Object.entries(settings.modelProviders)) {
|
|
40
|
+
const protocol = Object.hasOwn(mappings, provider) ? mappings[provider] : provider;
|
|
41
|
+
if (typeof protocol !== "string" || !PROTOCOLS.has(protocol) || !Array.isArray(rows))
|
|
42
|
+
continue;
|
|
43
|
+
for (const row of rows) {
|
|
44
|
+
if (!isRecord(row) || typeof row.id !== "string")
|
|
45
|
+
continue;
|
|
46
|
+
const model = row.id;
|
|
47
|
+
if (!model.trim())
|
|
48
|
+
continue;
|
|
49
|
+
if (row.baseUrl !== undefined && typeof row.baseUrl !== "string")
|
|
50
|
+
continue;
|
|
51
|
+
const baseUrl = typeof row.baseUrl === "string" && row.baseUrl ? row.baseUrl : undefined;
|
|
52
|
+
if (baseUrl) {
|
|
53
|
+
try {
|
|
54
|
+
new URL(baseUrl);
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
throw new Error("A Qwen model has an invalid endpoint. Fix its baseUrl in Qwen settings and refresh models.");
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
const identity = JSON.stringify([protocol, model, baseUrl ?? null]);
|
|
61
|
+
if (seen.has(identity))
|
|
62
|
+
continue;
|
|
63
|
+
seen.add(identity);
|
|
64
|
+
if (row.imageOnly === true || row.voiceOnly === true || row.fastOnly === true)
|
|
65
|
+
continue;
|
|
66
|
+
routes.push({ id: `${model}(${protocol})`, model, provider, protocol, baseUrl,
|
|
67
|
+
label: typeof row.name === "string" && row.name ? row.name : model,
|
|
68
|
+
envKey: typeof row.envKey === "string" ? row.envKey : undefined });
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
// Qwen's ACP route identity, from QwenLM/qwen-code acpModelUtils.ts
|
|
72
|
+
// (Copyright 2025 Qwen Team, Apache-2.0). Never publish endpoints or env keys.
|
|
73
|
+
const counts = new Map();
|
|
74
|
+
for (const route of routes)
|
|
75
|
+
counts.set(route.id, (counts.get(route.id) ?? 0) + 1);
|
|
76
|
+
for (const route of routes) {
|
|
77
|
+
if (counts.get(route.id) === 1)
|
|
78
|
+
continue;
|
|
79
|
+
const endpoint = route.baseUrl ?? (route.protocol === "openai" ? "https://api.openai.com/v1" : "");
|
|
80
|
+
let publicEndpoint = null;
|
|
81
|
+
if (endpoint) {
|
|
82
|
+
try {
|
|
83
|
+
const url = new URL(endpoint);
|
|
84
|
+
url.username = "";
|
|
85
|
+
url.password = "";
|
|
86
|
+
url.search = "";
|
|
87
|
+
url.hash = "";
|
|
88
|
+
publicEndpoint = url.href;
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
throw new Error("A Qwen model has an invalid endpoint. Fix its baseUrl in Qwen settings and refresh models.");
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
const identity = [route.id, route.label, route.envKey ?? null, route.baseUrl === undefined, publicEndpoint];
|
|
95
|
+
route.id = `qwen-route:v1:${createHash("sha256").update(JSON.stringify(identity)).digest("base64url").slice(0, 16)}`;
|
|
96
|
+
}
|
|
97
|
+
if (new Set(routes.map((route) => route.id)).size !== routes.length) {
|
|
98
|
+
throw new Error("Qwen has indistinguishable model routes. Give them distinct names, envKey values, or public endpoints in Qwen settings.");
|
|
99
|
+
}
|
|
100
|
+
return routes;
|
|
101
|
+
}
|
|
102
|
+
/** Public metadata only. Use Qwen's provider-qualified ACP selectors, not raw model IDs. */
|
|
103
|
+
export function readQwenModelCatalog(env = process.env) {
|
|
104
|
+
const options = readQwenRoutes(env).map(({ id, model, label, provider }) => ({
|
|
105
|
+
id, label: qwenModelLabel(model, label), custom: true, provider,
|
|
106
|
+
}));
|
|
107
|
+
return { default: options[0]?.id ?? "", options };
|
|
12
108
|
}
|
|
13
109
|
function envKeyFor(hostId) {
|
|
14
110
|
return `OPENMAUSBOT_QWEN_${hostId.toUpperCase().replace(/[^A-Z0-9]+/g, "_")}_API_KEY`;
|
|
@@ -68,8 +164,46 @@ export function ensureQwenInjectModel(modelId, env = process.env) {
|
|
|
68
164
|
return inject.model;
|
|
69
165
|
}
|
|
70
166
|
async function resolveModels(env) {
|
|
71
|
-
const catalog = await mergeLocalInject(
|
|
72
|
-
|
|
167
|
+
const catalog = await mergeLocalInject(readQwenModelCatalog(env), env);
|
|
168
|
+
const routes = readQwenRoutes(env);
|
|
169
|
+
const replacements = new Map();
|
|
170
|
+
for (const option of catalog.options) {
|
|
171
|
+
const injected = decodeInjectId(option.id);
|
|
172
|
+
if (!injected)
|
|
173
|
+
continue;
|
|
174
|
+
const endpoint = localHost(injected.host)?.baseUrl;
|
|
175
|
+
for (const route of routes) {
|
|
176
|
+
if (route.protocol === "openai" && route.model === injected.model && route.baseUrl === endpoint) {
|
|
177
|
+
replacements.set(route.id, option.id);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
const options = catalog.options.filter((option) => !replacements.has(option.id));
|
|
182
|
+
const preferred = replacements.get(catalog.default) ?? catalog.default;
|
|
183
|
+
return { default: options.some((option) => option.id === preferred) ? preferred : options[0]?.id ?? "", options };
|
|
184
|
+
}
|
|
185
|
+
export function resolveQwenTurnModel(model, env) {
|
|
186
|
+
if (!model)
|
|
187
|
+
return model;
|
|
188
|
+
const injected = decodeInjectId(model);
|
|
189
|
+
if (injected)
|
|
190
|
+
ensureQwenInjectModel(model, env);
|
|
191
|
+
const routes = readQwenRoutes(env);
|
|
192
|
+
if (injected) {
|
|
193
|
+
const route = routes.find((row) => row.protocol === "openai" && row.model === injected.model
|
|
194
|
+
&& row.baseUrl === localHost(injected.host)?.baseUrl);
|
|
195
|
+
if (!route)
|
|
196
|
+
throw new Error("Qwen could not configure this local model. Refresh models and select it again.");
|
|
197
|
+
return route.id;
|
|
198
|
+
}
|
|
199
|
+
if (routes.some((route) => route.id === model))
|
|
200
|
+
return model;
|
|
201
|
+
const matches = routes.filter((route) => route.model === model);
|
|
202
|
+
if (matches.length > 1)
|
|
203
|
+
throw new Error("This Qwen model has multiple providers. Refresh models and select the intended provider again.");
|
|
204
|
+
if (!matches.length)
|
|
205
|
+
throw new Error("This Qwen model is no longer configured. Refresh models and select it again.");
|
|
206
|
+
return matches[0].id;
|
|
73
207
|
}
|
|
74
208
|
const support = {
|
|
75
209
|
driverKind: "qwenAgent",
|
|
@@ -77,7 +211,7 @@ const support = {
|
|
|
77
211
|
access: "custom",
|
|
78
212
|
models: EMPTY,
|
|
79
213
|
resolveModels,
|
|
80
|
-
resolveTurnModel:
|
|
214
|
+
resolveTurnModel: resolveQwenTurnModel,
|
|
81
215
|
defaultCli: "qwen",
|
|
82
216
|
nativeSource: "qwen.acp",
|
|
83
217
|
loginNote: "Qwen Code CLI is not installed",
|
|
@@ -89,7 +223,10 @@ const support = {
|
|
|
89
223
|
},
|
|
90
224
|
docsUrl: "https://qwenlm.github.io/qwen-code-docs/en/users/overview/",
|
|
91
225
|
},
|
|
92
|
-
|
|
226
|
+
// A raw -m only changes the model within the saved provider. ACP switches
|
|
227
|
+
// the complete route and confirms it before any prompt leaves OMB.
|
|
228
|
+
spawnArgs: () => ["--acp"],
|
|
229
|
+
selectModel: { configId: "model" },
|
|
93
230
|
pickAuthMethod: () => null,
|
|
94
231
|
authFailure: "continue",
|
|
95
232
|
isAuthenticated: () => true,
|
|
@@ -64,7 +64,7 @@ const WEEKDAYS = [
|
|
|
64
64
|
const ROUTINE_SCHEDULE_SCHEMA = {
|
|
65
65
|
type: "object",
|
|
66
66
|
additionalProperties: false,
|
|
67
|
-
description: 'Either {"type":"once","at":RFC3339} for one future run, {"type":"weekly","time":"HH:MM","weekdays":[...]} for chosen days, {"type":"daily","time":"HH:MM"} for every day, or {"type":"interval","every_minutes":15
|
|
67
|
+
description: 'Either {"type":"once","at":RFC3339} for one future run, {"type":"weekly","time":"HH:MM","weekdays":[...]} for chosen days, {"type":"daily","time":"HH:MM"} for every day, or {"type":"interval","every_minutes":15} to repeat. Intervals can optionally be limited with weekdays, window_start + window_end, and ends_at.',
|
|
68
68
|
properties: {
|
|
69
69
|
type: {
|
|
70
70
|
type: "string",
|
|
@@ -82,7 +82,7 @@ const ROUTINE_SCHEDULE_SCHEMA = {
|
|
|
82
82
|
weekdays: {
|
|
83
83
|
type: "array",
|
|
84
84
|
items: { type: "string", enum: WEEKDAYS },
|
|
85
|
-
description: "
|
|
85
|
+
description: "For type weekly: required run days. For type interval: optional allowed days. Values use the computer's local timezone.",
|
|
86
86
|
},
|
|
87
87
|
every_minutes: {
|
|
88
88
|
type: "integer",
|
|
@@ -94,6 +94,30 @@ const ROUTINE_SCHEDULE_SCHEMA = {
|
|
|
94
94
|
type: "string",
|
|
95
95
|
description: "Optional for type interval: RFC3339 date-time with an explicit timezone offset that anchors the cadence. Omit to start one interval after confirmation.",
|
|
96
96
|
},
|
|
97
|
+
window_start: {
|
|
98
|
+
type: "string",
|
|
99
|
+
description: "Optional for type interval, together with window_end: local 24-hour HH:MM when runs may begin, inclusive.",
|
|
100
|
+
},
|
|
101
|
+
window_end: {
|
|
102
|
+
type: "string",
|
|
103
|
+
description: "Optional for type interval, together with window_start: local 24-hour HH:MM when the allowed window ends, exclusive. It must be later on the same day.",
|
|
104
|
+
},
|
|
105
|
+
ends_at: {
|
|
106
|
+
type: "string",
|
|
107
|
+
description: "Optional for type interval: inclusive RFC3339 date-time cutoff with an explicit timezone offset.",
|
|
108
|
+
},
|
|
109
|
+
every_day: {
|
|
110
|
+
type: "boolean",
|
|
111
|
+
description: "Only for an interval update: true removes an existing weekday restriction.",
|
|
112
|
+
},
|
|
113
|
+
all_day: {
|
|
114
|
+
type: "boolean",
|
|
115
|
+
description: "Only for an interval update: true removes an existing time-window restriction.",
|
|
116
|
+
},
|
|
117
|
+
never_ends: {
|
|
118
|
+
type: "boolean",
|
|
119
|
+
description: "Only for an interval update: true removes an existing end cutoff.",
|
|
120
|
+
},
|
|
97
121
|
},
|
|
98
122
|
required: ["type"],
|
|
99
123
|
};
|
|
@@ -111,7 +135,7 @@ const SHORT_WEEKDAYS = {
|
|
|
111
135
|
};
|
|
112
136
|
const SUPPORTED_SCHEDULES = 'Supported schedules: {"type":"once","at":"2026-09-01T09:00:00+05:30"} (future RFC3339 with explicit offset), ' +
|
|
113
137
|
'{"type":"weekly","time":"09:00","weekdays":["monday","friday"]}, {"type":"daily","time":"09:00"}, ' +
|
|
114
|
-
'or {"type":"interval","every_minutes":15}.';
|
|
138
|
+
'or {"type":"interval","every_minutes":15,"weekdays":["monday","friday"],"window_start":"09:00","window_end":"17:00"}.';
|
|
115
139
|
/** A schedule as the harness accepts it, or a message telling the model
|
|
116
140
|
* exactly what to send instead. Coercion first, error second: models
|
|
117
141
|
* routinely stringify nested objects, say "daily", or shorten weekday
|
|
@@ -130,6 +154,20 @@ function normalizeScheduleInput(args) {
|
|
|
130
154
|
if (!jsonRecord(raw))
|
|
131
155
|
return { error: `The schedule must be a JSON object. ${SUPPORTED_SCHEDULES}` };
|
|
132
156
|
const type = typeof raw.type === "string" ? raw.type.trim().toLowerCase() : "";
|
|
157
|
+
const fields = type === "once"
|
|
158
|
+
? ["type", "at"]
|
|
159
|
+
: type === "weekly" || type === "daily"
|
|
160
|
+
? ["type", "time", "weekdays"]
|
|
161
|
+
: type === "interval"
|
|
162
|
+
? ["type", "every_minutes", "everyMinutes", "starts_at", "anchorAt", "weekdays", "every_day", "window_start", "window_end", "window", "all_day", "ends_at", "endsAt", "never_ends"]
|
|
163
|
+
: null;
|
|
164
|
+
// Provider conversions may send unused optional fields as null. Ignore
|
|
165
|
+
// those, but never silently discard an actual scheduling constraint (for
|
|
166
|
+
// example timezone or a misspelled starts_at) and approve different work.
|
|
167
|
+
const unsupported = fields && Object.keys(raw).find((key) => raw[key] != null && !fields.includes(key));
|
|
168
|
+
if (unsupported) {
|
|
169
|
+
return { error: `Unsupported ${type} schedule field "${unsupported}". Weekly and daily times use the computer's timezone from list_routines. ${SUPPORTED_SCHEDULES}` };
|
|
170
|
+
}
|
|
133
171
|
if (type === "once") {
|
|
134
172
|
if (typeof raw.at !== "string" || !raw.at.trim()) {
|
|
135
173
|
return { error: `A once schedule needs "at": a future RFC3339 date-time with an explicit offset, for example 2026-09-01T09:00:00+05:30.` };
|
|
@@ -167,6 +205,16 @@ function normalizeScheduleInput(args) {
|
|
|
167
205
|
return { schedule: { type: "weekly", time, weekdays: normalized } };
|
|
168
206
|
}
|
|
169
207
|
if (type === "interval") {
|
|
208
|
+
for (const flag of ["every_day", "all_day", "never_ends"]) {
|
|
209
|
+
if (raw[flag] != null && typeof raw[flag] !== "boolean") {
|
|
210
|
+
return { error: `"${flag}" must be true or false.` };
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
if (raw.window != null && (!jsonRecord(raw.window)
|
|
214
|
+
|| Object.keys(raw.window).some((key) => key !== "start" && key !== "end")
|
|
215
|
+
|| typeof raw.window.start !== "string" || typeof raw.window.end !== "string")) {
|
|
216
|
+
return { error: '"window" must contain "start" and "end" in HH:MM, for example {"start":"09:00","end":"17:00"}.' };
|
|
217
|
+
}
|
|
170
218
|
const rawMinutes = raw.every_minutes ?? raw.everyMinutes;
|
|
171
219
|
const everyMinutes = Number(rawMinutes);
|
|
172
220
|
if (!Number.isInteger(everyMinutes) || everyMinutes < 5 || everyMinutes > 1_440) {
|
|
@@ -176,11 +224,68 @@ function normalizeScheduleInput(args) {
|
|
|
176
224
|
if (rawStart !== undefined && (typeof rawStart !== "string" || !rawStart.trim())) {
|
|
177
225
|
return { error: '"starts_at" must be an RFC3339 date-time with an explicit timezone offset.' };
|
|
178
226
|
}
|
|
227
|
+
if (raw.every_day === true && Array.isArray(raw.weekdays) && raw.weekdays.length > 0) {
|
|
228
|
+
return { error: 'Choose interval "weekdays" or "every_day", not both.' };
|
|
229
|
+
}
|
|
230
|
+
let intervalWeekdays;
|
|
231
|
+
if (raw.every_day === true) {
|
|
232
|
+
intervalWeekdays = null;
|
|
233
|
+
}
|
|
234
|
+
else if (raw.weekdays !== undefined) {
|
|
235
|
+
if (!Array.isArray(raw.weekdays) || raw.weekdays.length === 0) {
|
|
236
|
+
return { error: 'Interval "weekdays" must contain at least one full weekday name.' };
|
|
237
|
+
}
|
|
238
|
+
intervalWeekdays = [];
|
|
239
|
+
for (const day of raw.weekdays) {
|
|
240
|
+
const lower = String(day).trim().toLowerCase();
|
|
241
|
+
const full = WEEKDAYS.includes(lower)
|
|
242
|
+
? lower
|
|
243
|
+
: Object.hasOwn(SHORT_WEEKDAYS, lower)
|
|
244
|
+
? SHORT_WEEKDAYS[lower]
|
|
245
|
+
: undefined;
|
|
246
|
+
if (!full)
|
|
247
|
+
return { error: `Unsupported weekday "${String(day)}". Use full names: ${WEEKDAYS.join(", ")}.` };
|
|
248
|
+
if (!intervalWeekdays.includes(full))
|
|
249
|
+
intervalWeekdays.push(full);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
const rawWindow = jsonRecord(raw.window) ? raw.window : undefined;
|
|
253
|
+
const windowStart = raw.window_start ?? rawWindow?.start;
|
|
254
|
+
const windowEnd = raw.window_end ?? rawWindow?.end;
|
|
255
|
+
if (raw.all_day === true && (windowStart !== undefined || windowEnd !== undefined)) {
|
|
256
|
+
return { error: 'Choose window_start + window_end or "all_day", not both.' };
|
|
257
|
+
}
|
|
258
|
+
let window;
|
|
259
|
+
if (raw.all_day === true) {
|
|
260
|
+
window = null;
|
|
261
|
+
}
|
|
262
|
+
else if (windowStart !== undefined || windowEnd !== undefined) {
|
|
263
|
+
if (typeof windowStart !== "string" || !windowStart.trim() || typeof windowEnd !== "string" || !windowEnd.trim()) {
|
|
264
|
+
return { error: 'An interval time window needs both "window_start" and "window_end" in 24-hour HH:MM.' };
|
|
265
|
+
}
|
|
266
|
+
window = { start: windowStart.trim(), end: windowEnd.trim() };
|
|
267
|
+
}
|
|
268
|
+
const rawEnd = raw.ends_at ?? raw.endsAt;
|
|
269
|
+
if (raw.never_ends === true && rawEnd !== undefined) {
|
|
270
|
+
return { error: 'Choose "ends_at" or "never_ends", not both.' };
|
|
271
|
+
}
|
|
272
|
+
let endsAt;
|
|
273
|
+
if (raw.never_ends === true)
|
|
274
|
+
endsAt = null;
|
|
275
|
+
else if (rawEnd !== undefined) {
|
|
276
|
+
if (typeof rawEnd !== "string" || !rawEnd.trim()) {
|
|
277
|
+
return { error: '"ends_at" must be an RFC3339 date-time with an explicit timezone offset.' };
|
|
278
|
+
}
|
|
279
|
+
endsAt = rawEnd.trim();
|
|
280
|
+
}
|
|
179
281
|
return {
|
|
180
282
|
schedule: {
|
|
181
283
|
type: "interval",
|
|
182
284
|
everyMinutes,
|
|
183
285
|
...(typeof rawStart === "string" ? { anchorAt: rawStart.trim() } : {}),
|
|
286
|
+
...(intervalWeekdays !== undefined ? { weekdays: intervalWeekdays } : {}),
|
|
287
|
+
...(window !== undefined ? { window } : {}),
|
|
288
|
+
...(endsAt !== undefined ? { endsAt } : {}),
|
|
184
289
|
},
|
|
185
290
|
};
|
|
186
291
|
}
|
|
@@ -500,7 +605,29 @@ function routineAction(value) {
|
|
|
500
605
|
}
|
|
501
606
|
function routineFields(args) {
|
|
502
607
|
const fields = {};
|
|
503
|
-
|
|
608
|
+
// list_routines returns the harness names. Accept those when a model
|
|
609
|
+
// copies back a definition, as we already do for interval fields.
|
|
610
|
+
if (args.run_on != null && args.runOn != null && args.run_on !== args.runOn) {
|
|
611
|
+
return { fields, error: "Choose one run_on destination; run_on and runOn disagree." };
|
|
612
|
+
}
|
|
613
|
+
if (args.timeout_minutes != null && args.timeoutMinutes != null && args.timeout_minutes !== args.timeoutMinutes) {
|
|
614
|
+
return { fields, error: "Choose one timeout_minutes limit; timeout_minutes and timeoutMinutes disagree." };
|
|
615
|
+
}
|
|
616
|
+
const runOn = args.run_on ?? args.runOn;
|
|
617
|
+
const timeoutMinutes = args.timeout_minutes ?? args.timeoutMinutes;
|
|
618
|
+
if (runOn != null && runOn !== "maus" && runOn !== "cloud") {
|
|
619
|
+
return { fields, error: 'run_on must be "maus" or "cloud".' };
|
|
620
|
+
}
|
|
621
|
+
if (timeoutMinutes != null && (typeof timeoutMinutes !== "number" || !Number.isInteger(timeoutMinutes) || timeoutMinutes < 5 || timeoutMinutes > 240)) {
|
|
622
|
+
return { fields, error: "timeout_minutes must be a whole number from 5 to 240. Use clear_timeout to remove a limit." };
|
|
623
|
+
}
|
|
624
|
+
if (args.continuity != null && typeof args.continuity !== "boolean") {
|
|
625
|
+
return { fields, error: "continuity must be true or false." };
|
|
626
|
+
}
|
|
627
|
+
if (args.clear_timeout != null && typeof args.clear_timeout !== "boolean") {
|
|
628
|
+
return { fields, error: "clear_timeout must be true or false." };
|
|
629
|
+
}
|
|
630
|
+
if (args.clear_timeout === true && timeoutMinutes != null) {
|
|
504
631
|
return { fields, error: "Choose timeout_minutes or clear_timeout, not both." };
|
|
505
632
|
}
|
|
506
633
|
if (typeof args.name === "string")
|
|
@@ -513,12 +640,12 @@ function routineFields(args) {
|
|
|
513
640
|
return { fields, error: normalized.error };
|
|
514
641
|
fields.schedule = normalized.schedule;
|
|
515
642
|
}
|
|
516
|
-
if (
|
|
517
|
-
fields.runOn =
|
|
643
|
+
if (runOn != null)
|
|
644
|
+
fields.runOn = runOn;
|
|
518
645
|
if (args.clear_timeout === true)
|
|
519
646
|
fields.timeoutMinutes = null;
|
|
520
|
-
else if (
|
|
521
|
-
fields.timeoutMinutes =
|
|
647
|
+
else if (timeoutMinutes != null)
|
|
648
|
+
fields.timeoutMinutes = timeoutMinutes;
|
|
522
649
|
if (typeof args.continuity === "boolean")
|
|
523
650
|
fields.continuity = args.continuity;
|
|
524
651
|
return { fields };
|
|
@@ -45,6 +45,7 @@ function loginFailure(output) {
|
|
|
45
45
|
* output stays in bounded private memory; only a device URL/code is exposed. */
|
|
46
46
|
export class CodexDeviceAuthController {
|
|
47
47
|
flow = null;
|
|
48
|
+
command = null;
|
|
48
49
|
disposed = false;
|
|
49
50
|
options;
|
|
50
51
|
constructor(options) { this.options = options; }
|
|
@@ -121,12 +122,111 @@ export class CodexDeviceAuthController {
|
|
|
121
122
|
await this.flow?.stopping;
|
|
122
123
|
if (this.flow?.terminationFailed)
|
|
123
124
|
throw new Error(this.flow.status.message);
|
|
125
|
+
if (this.command && !await this.stopChild(this.command)) {
|
|
126
|
+
throw new Error("Codex could not be stopped on this server. Ask the server administrator to stop the account command before trying again.");
|
|
127
|
+
}
|
|
124
128
|
}
|
|
125
129
|
async dispose() {
|
|
126
130
|
this.disposed = true;
|
|
127
131
|
await this.cancel();
|
|
128
132
|
this.flow = null;
|
|
129
133
|
}
|
|
134
|
+
/** Remove the ChatGPT sign-in Codex stores for this server account so a
|
|
135
|
+
* different account can connect. A sign-in in progress is never pulled
|
|
136
|
+
* away underneath the browser completing it. */
|
|
137
|
+
async signOut() {
|
|
138
|
+
if (this.disposed)
|
|
139
|
+
throw new Error("This provider was removed. Refresh Settings before signing out.");
|
|
140
|
+
if (this.flow?.status.phase === "waiting")
|
|
141
|
+
throw new Error("Finish or cancel the ChatGPT sign-in in progress before signing out.");
|
|
142
|
+
await this.flow?.stopping;
|
|
143
|
+
if (this.disposed)
|
|
144
|
+
throw new Error("This provider was removed. Refresh Settings before signing out.");
|
|
145
|
+
const env = this.options.environment();
|
|
146
|
+
const home = env.HOME || env.USERPROFILE || homedir();
|
|
147
|
+
if (!isAbsolute(home) || (env.CODEX_HOME && !isAbsolute(env.CODEX_HOME))) {
|
|
148
|
+
throw new Error("Use an absolute HOME and CODEX_HOME path for this server's Codex provider before signing out.");
|
|
149
|
+
}
|
|
150
|
+
const homeKey = canonicalPath(resolve(env.CODEX_HOME || join(home, ".codex")));
|
|
151
|
+
if (authenticatingHomes.has(homeKey)) {
|
|
152
|
+
throw new Error("A ChatGPT sign-in is running for this server account. Finish or cancel it before signing out.");
|
|
153
|
+
}
|
|
154
|
+
authenticatingHomes.add(homeKey);
|
|
155
|
+
try {
|
|
156
|
+
const before = await this.exec(["login", "status"], env, home);
|
|
157
|
+
if (before.code === 1 && /^not logged in\b/im.test(before.output))
|
|
158
|
+
return;
|
|
159
|
+
if (before.code !== 0 || !/^logged in.*chatgpt\b/im.test(before.output)) {
|
|
160
|
+
throw new Error("Codex did not confirm a ChatGPT sign-in. Other authentication methods will not be removed; ask the server administrator to check the account.");
|
|
161
|
+
}
|
|
162
|
+
const logout = await this.exec(["logout"], env, home);
|
|
163
|
+
if (logout.code !== 0)
|
|
164
|
+
throw new Error("Codex could not remove the sign-in on this server. Check the server's Codex installation and try again.");
|
|
165
|
+
// The command's own report is not enough: confirm with the same status
|
|
166
|
+
// check that decides whether bots may run on this account.
|
|
167
|
+
const status = await this.exec(["login", "status"], env, home);
|
|
168
|
+
if (status.code !== 1 || !/^not logged in\b/im.test(status.output)) {
|
|
169
|
+
throw new Error("Codex still reports a sign-in on this server. Update Codex and check its login status before trying again.");
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
finally {
|
|
173
|
+
// If the OS cannot stop a command, it may still change credentials.
|
|
174
|
+
// Keep the home reserved until that exact child actually exits.
|
|
175
|
+
const child = this.command;
|
|
176
|
+
if (child && child.exitCode === null && child.signalCode === null) {
|
|
177
|
+
child.once("close", () => authenticatingHomes.delete(homeKey));
|
|
178
|
+
}
|
|
179
|
+
else
|
|
180
|
+
authenticatingHomes.delete(homeKey);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
/** One bounded, non-interactive Codex command. Its output stays here;
|
|
184
|
+
* callers see an exit code and a status-line match, never the text. */
|
|
185
|
+
exec(args, env, cwd) {
|
|
186
|
+
return new Promise((resolveExec, rejectExec) => {
|
|
187
|
+
if (this.disposed) {
|
|
188
|
+
rejectExec(new Error("This provider was removed. Refresh Settings before signing out."));
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
let child;
|
|
192
|
+
try {
|
|
193
|
+
child = spawnCli(this.options.cli, args, { env: { ...env, NO_COLOR: "1" }, cwd, stdio: ["pipe", "pipe", "pipe"] });
|
|
194
|
+
}
|
|
195
|
+
catch {
|
|
196
|
+
rejectExec(new Error("Codex could not start on this server. Install or update the configured Codex CLI, then try again."));
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
this.command = child;
|
|
200
|
+
child.stdin.end();
|
|
201
|
+
let output = "";
|
|
202
|
+
const receive = (chunk) => {
|
|
203
|
+
if (output.length < MAX_OUTPUT)
|
|
204
|
+
output += chunk.toString("utf8").slice(0, MAX_OUTPUT - output.length);
|
|
205
|
+
};
|
|
206
|
+
child.stdout.on("data", receive);
|
|
207
|
+
child.stderr.on("data", receive);
|
|
208
|
+
// A hung CLI must not hold the credential-home lock forever.
|
|
209
|
+
const timer = setTimeout(() => {
|
|
210
|
+
void this.stopChild(child).catch(() => false).then((stopped) => {
|
|
211
|
+
if (!stopped)
|
|
212
|
+
rejectExec(new Error("Codex could not be stopped on this server. Ask the server administrator to stop the account command before trying again."));
|
|
213
|
+
});
|
|
214
|
+
}, this.options.startupTimeoutMs ?? 30_000);
|
|
215
|
+
timer.unref();
|
|
216
|
+
child.once("error", (error) => {
|
|
217
|
+
clearTimeout(timer);
|
|
218
|
+
rejectExec(new Error(error.code === "ENOENT"
|
|
219
|
+
? "Codex is not installed on this server. Run npm install -g @openai/codex@latest on the server, then try again."
|
|
220
|
+
: "Codex could not start on this server. Check the configured CLI path and its executable permissions."));
|
|
221
|
+
});
|
|
222
|
+
child.once("close", (code) => {
|
|
223
|
+
clearTimeout(timer);
|
|
224
|
+
if (this.command === child)
|
|
225
|
+
this.command = null;
|
|
226
|
+
resolveExec({ code, output: stripVTControlCharacters(output) });
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
}
|
|
130
230
|
run(flow, args, env, cwd, done, parsePrompt = false) {
|
|
131
231
|
if (flow.status.phase !== "waiting")
|
|
132
232
|
return;
|
|
@@ -188,7 +288,7 @@ export class CodexDeviceAuthController {
|
|
|
188
288
|
else
|
|
189
289
|
flow.reject(new Error(message ?? "ChatGPT sign-in did not finish."));
|
|
190
290
|
}
|
|
191
|
-
flow.stopping = this.stopChild(flow).catch(() => false).then((stopped) => {
|
|
291
|
+
flow.stopping = this.stopChild(flow.child).catch(() => false).then((stopped) => {
|
|
192
292
|
const release = () => authenticatingHomes.delete(flow.homeKey);
|
|
193
293
|
if (stopped) {
|
|
194
294
|
release();
|
|
@@ -206,8 +306,7 @@ export class CodexDeviceAuthController {
|
|
|
206
306
|
if (phase === "succeeded")
|
|
207
307
|
void this.options.onAuthenticated?.().catch(() => { });
|
|
208
308
|
}
|
|
209
|
-
async stopChild(
|
|
210
|
-
const child = flow.child;
|
|
309
|
+
async stopChild(child) {
|
|
211
310
|
if (!child || await killCliTree(child, this.options.terminateTimeoutMs ?? 1500))
|
|
212
311
|
return true;
|
|
213
312
|
// A broken CLI may ignore SIGTERM. Do not leave a stale device flow able
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// Let Codex resolve its effective account, including keyring credentials.
|
|
2
|
+
// Reading auth.json directly can label an API-key or keyring login with an
|
|
3
|
+
// old file's email. Only the protocol's display email leaves this helper.
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { isAbsolute, join, resolve } from "node:path";
|
|
6
|
+
import { killCliTree, spawnCli } from "../procs.js";
|
|
7
|
+
const MAX_OUTPUT = 16_384;
|
|
8
|
+
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
9
|
+
/** Where this environment's Codex keeps its credentials, or null when the
|
|
10
|
+
* configured home is not absolute (the login controller refuses those too). */
|
|
11
|
+
export function codexHome(env) {
|
|
12
|
+
if (env.CODEX_HOME)
|
|
13
|
+
return isAbsolute(env.CODEX_HOME) ? resolve(env.CODEX_HOME) : null;
|
|
14
|
+
const home = env.HOME || env.USERPROFILE || homedir();
|
|
15
|
+
return isAbsolute(home) ? join(home, ".codex") : null;
|
|
16
|
+
}
|
|
17
|
+
/** Read-only account metadata. Unsupported CLIs and uncertain responses stay
|
|
18
|
+
* unnamed; never fall back to a potentially inactive credential file. */
|
|
19
|
+
export async function codexAccountEmail(cli, env, timeoutMs = 3_000) {
|
|
20
|
+
const cwd = env.HOME || env.USERPROFILE || homedir();
|
|
21
|
+
if (!isAbsolute(cwd) || !codexHome(env))
|
|
22
|
+
return null;
|
|
23
|
+
return new Promise((resolveEmail) => {
|
|
24
|
+
let child;
|
|
25
|
+
try {
|
|
26
|
+
child = spawnCli(cli, ["app-server"], { cwd, env, stdio: ["pipe", "pipe", "pipe"] });
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
resolveEmail(null);
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
let finishing = false;
|
|
33
|
+
let buffer = "";
|
|
34
|
+
let outputBytes = 0;
|
|
35
|
+
let initialized = false;
|
|
36
|
+
const finish = async (email) => {
|
|
37
|
+
if (finishing)
|
|
38
|
+
return;
|
|
39
|
+
finishing = true;
|
|
40
|
+
clearTimeout(timer);
|
|
41
|
+
let stopped = await killCliTree(child, 1_000);
|
|
42
|
+
if (!stopped && child.pid) {
|
|
43
|
+
try {
|
|
44
|
+
if (process.platform === "win32")
|
|
45
|
+
child.kill("SIGKILL");
|
|
46
|
+
else
|
|
47
|
+
process.kill(-child.pid, "SIGKILL");
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
try {
|
|
51
|
+
child.kill("SIGKILL");
|
|
52
|
+
}
|
|
53
|
+
catch { /* already gone */ }
|
|
54
|
+
}
|
|
55
|
+
stopped = await killCliTree(child, 1_000);
|
|
56
|
+
}
|
|
57
|
+
resolveEmail(stopped ? email : null);
|
|
58
|
+
};
|
|
59
|
+
const timer = setTimeout(() => { void finish(null); }, timeoutMs);
|
|
60
|
+
timer.unref();
|
|
61
|
+
const send = (message) => {
|
|
62
|
+
try {
|
|
63
|
+
child.stdin.write(`${JSON.stringify(message)}\n`);
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
void finish(null);
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
child.stdin.on("error", () => { void finish(null); });
|
|
70
|
+
child.stderr.resume(); // Do not retain or log raw CLI errors or credentials.
|
|
71
|
+
child.on("error", () => { void finish(null); });
|
|
72
|
+
child.on("close", () => { void finish(null); });
|
|
73
|
+
child.stdout.setEncoding("utf8");
|
|
74
|
+
child.stdout.on("data", (chunk) => {
|
|
75
|
+
if (finishing)
|
|
76
|
+
return;
|
|
77
|
+
outputBytes += Buffer.byteLength(chunk);
|
|
78
|
+
if (outputBytes > MAX_OUTPUT) {
|
|
79
|
+
void finish(null);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
buffer += chunk;
|
|
83
|
+
let newline;
|
|
84
|
+
while (!finishing && (newline = buffer.indexOf("\n")) !== -1) {
|
|
85
|
+
const line = buffer.slice(0, newline);
|
|
86
|
+
buffer = buffer.slice(newline + 1);
|
|
87
|
+
if (!line.trim())
|
|
88
|
+
continue;
|
|
89
|
+
let message;
|
|
90
|
+
try {
|
|
91
|
+
message = JSON.parse(line);
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
void finish(null);
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
if (!initialized && message?.id === 1) {
|
|
98
|
+
if (message.error || !message.result) {
|
|
99
|
+
void finish(null);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
initialized = true;
|
|
103
|
+
send({ method: "initialized", params: {} });
|
|
104
|
+
send({ id: 2, method: "account/read", params: { refreshToken: false } });
|
|
105
|
+
}
|
|
106
|
+
else if (initialized && message?.id === 2) {
|
|
107
|
+
const account = message.error ? null : message.result?.account;
|
|
108
|
+
const email = account?.type === "chatgpt" ? account.email : null;
|
|
109
|
+
void finish(typeof email === "string" && email.length <= 254 && EMAIL.test(email) && !/[\p{Cc}\p{Cf}]/u.test(email) ? email : null);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
send({ id: 1, method: "initialize", params: { clientInfo: { name: "openmausbot", version: "1" } } });
|
|
114
|
+
});
|
|
115
|
+
}
|