@chrysb/alphaclaw 0.1.0
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/bin/alphaclaw.js +338 -0
- package/lib/public/icons/chevron-down.svg +9 -0
- package/lib/public/js/app.js +325 -0
- package/lib/public/js/components/badge.js +16 -0
- package/lib/public/js/components/channels.js +36 -0
- package/lib/public/js/components/credentials-modal.js +336 -0
- package/lib/public/js/components/device-pairings.js +72 -0
- package/lib/public/js/components/envars.js +354 -0
- package/lib/public/js/components/gateway.js +163 -0
- package/lib/public/js/components/google.js +223 -0
- package/lib/public/js/components/icons.js +23 -0
- package/lib/public/js/components/models.js +461 -0
- package/lib/public/js/components/pairings.js +74 -0
- package/lib/public/js/components/scope-picker.js +106 -0
- package/lib/public/js/components/toast.js +31 -0
- package/lib/public/js/components/welcome.js +541 -0
- package/lib/public/js/hooks/usePolling.js +29 -0
- package/lib/public/js/lib/api.js +196 -0
- package/lib/public/js/lib/model-config.js +88 -0
- package/lib/public/login.html +90 -0
- package/lib/public/setup.html +33 -0
- package/lib/scripts/systemctl +56 -0
- package/lib/server/auth-profiles.js +101 -0
- package/lib/server/commands.js +84 -0
- package/lib/server/constants.js +282 -0
- package/lib/server/env.js +78 -0
- package/lib/server/gateway.js +262 -0
- package/lib/server/helpers.js +192 -0
- package/lib/server/login-throttle.js +86 -0
- package/lib/server/onboarding/cron.js +51 -0
- package/lib/server/onboarding/github.js +49 -0
- package/lib/server/onboarding/index.js +127 -0
- package/lib/server/onboarding/openclaw.js +171 -0
- package/lib/server/onboarding/validation.js +107 -0
- package/lib/server/onboarding/workspace.js +52 -0
- package/lib/server/openclaw-version.js +179 -0
- package/lib/server/routes/auth.js +80 -0
- package/lib/server/routes/codex.js +204 -0
- package/lib/server/routes/google.js +390 -0
- package/lib/server/routes/models.js +68 -0
- package/lib/server/routes/onboarding.js +116 -0
- package/lib/server/routes/pages.js +21 -0
- package/lib/server/routes/pairings.js +134 -0
- package/lib/server/routes/proxy.js +29 -0
- package/lib/server/routes/system.js +213 -0
- package/lib/server.js +161 -0
- package/lib/setup/core-prompts/AGENTS.md +22 -0
- package/lib/setup/core-prompts/TOOLS.md +18 -0
- package/lib/setup/env.template +19 -0
- package/lib/setup/gitignore +12 -0
- package/lib/setup/hourly-git-sync.sh +86 -0
- package/lib/setup/skills/control-ui/SKILL.md +70 -0
- package/package.json +34 -0
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
const validateOnboardingInput = ({ vars, modelKey, resolveModelProvider, hasCodexOauthProfile }) => {
|
|
2
|
+
const kMaxOnboardingVars = 64;
|
|
3
|
+
const kMaxEnvKeyLength = 128;
|
|
4
|
+
const kMaxEnvValueLength = 4096;
|
|
5
|
+
if (!Array.isArray(vars)) {
|
|
6
|
+
return { ok: false, status: 400, error: "Missing vars array" };
|
|
7
|
+
}
|
|
8
|
+
if (vars.length > kMaxOnboardingVars) {
|
|
9
|
+
return {
|
|
10
|
+
ok: false,
|
|
11
|
+
status: 400,
|
|
12
|
+
error: `Too many environment variables (max ${kMaxOnboardingVars})`,
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
if (!modelKey || typeof modelKey !== "string" || !modelKey.includes("/")) {
|
|
16
|
+
return { ok: false, status: 400, error: "A model selection is required" };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
for (const entry of vars) {
|
|
20
|
+
const key = String(entry?.key || "");
|
|
21
|
+
const value = String(entry?.value || "");
|
|
22
|
+
if (!key) {
|
|
23
|
+
return { ok: false, status: 400, error: "Each variable must include a key" };
|
|
24
|
+
}
|
|
25
|
+
if (key.length > kMaxEnvKeyLength) {
|
|
26
|
+
return {
|
|
27
|
+
ok: false,
|
|
28
|
+
status: 400,
|
|
29
|
+
error: `Variable key is too long: ${key.slice(0, 32)}...`,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
if (value.length > kMaxEnvValueLength) {
|
|
33
|
+
return {
|
|
34
|
+
ok: false,
|
|
35
|
+
status: 400,
|
|
36
|
+
error: `Value too long for ${key} (max ${kMaxEnvValueLength} chars)`,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const varMap = Object.fromEntries(vars.map((v) => [v.key, v.value]));
|
|
42
|
+
const githubToken = String(varMap.GITHUB_TOKEN || "");
|
|
43
|
+
const githubRepoInput = String(varMap.GITHUB_WORKSPACE_REPO || "").trim();
|
|
44
|
+
const selectedProvider = resolveModelProvider(modelKey);
|
|
45
|
+
const hasCodexOauth = hasCodexOauthProfile();
|
|
46
|
+
const hasAiByProvider = {
|
|
47
|
+
anthropic: !!(varMap.ANTHROPIC_API_KEY || varMap.ANTHROPIC_TOKEN),
|
|
48
|
+
openai: !!varMap.OPENAI_API_KEY,
|
|
49
|
+
"openai-codex": !!(hasCodexOauth || varMap.OPENAI_API_KEY),
|
|
50
|
+
google: !!varMap.GEMINI_API_KEY,
|
|
51
|
+
};
|
|
52
|
+
const hasAnyAi = !!(
|
|
53
|
+
varMap.ANTHROPIC_API_KEY ||
|
|
54
|
+
varMap.ANTHROPIC_TOKEN ||
|
|
55
|
+
varMap.OPENAI_API_KEY ||
|
|
56
|
+
varMap.GEMINI_API_KEY ||
|
|
57
|
+
hasCodexOauth
|
|
58
|
+
);
|
|
59
|
+
const hasAi =
|
|
60
|
+
selectedProvider in hasAiByProvider
|
|
61
|
+
? hasAiByProvider[selectedProvider]
|
|
62
|
+
: hasAnyAi;
|
|
63
|
+
const hasGithub = !!(githubToken && githubRepoInput);
|
|
64
|
+
const hasChannel = !!(varMap.TELEGRAM_BOT_TOKEN || varMap.DISCORD_BOT_TOKEN);
|
|
65
|
+
|
|
66
|
+
if (!hasAi) {
|
|
67
|
+
if (selectedProvider === "openai-codex") {
|
|
68
|
+
return {
|
|
69
|
+
ok: false,
|
|
70
|
+
status: 400,
|
|
71
|
+
error: "Connect OpenAI Codex OAuth or provide OPENAI_API_KEY before continuing",
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
return {
|
|
75
|
+
ok: false,
|
|
76
|
+
status: 400,
|
|
77
|
+
error: `Missing credentials for selected provider "${selectedProvider}"`,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
if (!hasGithub) {
|
|
81
|
+
return {
|
|
82
|
+
ok: false,
|
|
83
|
+
status: 400,
|
|
84
|
+
error: "GitHub token and workspace repo are required",
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
if (!hasChannel) {
|
|
88
|
+
return {
|
|
89
|
+
ok: false,
|
|
90
|
+
status: 400,
|
|
91
|
+
error: "At least one channel token is required",
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
ok: true,
|
|
97
|
+
data: {
|
|
98
|
+
varMap,
|
|
99
|
+
githubToken,
|
|
100
|
+
githubRepoInput,
|
|
101
|
+
selectedProvider,
|
|
102
|
+
hasCodexOauth,
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
module.exports = { validateOnboardingInput };
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
const path = require("path");
|
|
2
|
+
const { kSetupDir } = require("../constants");
|
|
3
|
+
|
|
4
|
+
const resolveSetupUiUrl = (baseUrl) => {
|
|
5
|
+
const normalizedBaseUrl = String(baseUrl || "").trim().replace(/\/+$/, "");
|
|
6
|
+
if (normalizedBaseUrl) return normalizedBaseUrl;
|
|
7
|
+
|
|
8
|
+
const railwayPublicDomain = String(process.env.RAILWAY_PUBLIC_DOMAIN || "").trim();
|
|
9
|
+
if (railwayPublicDomain) {
|
|
10
|
+
return `https://${railwayPublicDomain}`;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const railwayStaticUrl = String(process.env.RAILWAY_STATIC_URL || "").trim().replace(
|
|
14
|
+
/\/+$/,
|
|
15
|
+
"",
|
|
16
|
+
);
|
|
17
|
+
if (railwayStaticUrl) return railwayStaticUrl;
|
|
18
|
+
|
|
19
|
+
return "http://localhost:3000";
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const syncBootstrapPromptFiles = ({ fs, workspaceDir, baseUrl }) => {
|
|
23
|
+
try {
|
|
24
|
+
const bootstrapDir = `${workspaceDir}/hooks/bootstrap`;
|
|
25
|
+
fs.mkdirSync(bootstrapDir, { recursive: true });
|
|
26
|
+
fs.copyFileSync(path.join(kSetupDir, "core-prompts", "AGENTS.md"), `${bootstrapDir}/AGENTS.md`);
|
|
27
|
+
const toolsTemplate = fs.readFileSync(path.join(kSetupDir, "core-prompts", "TOOLS.md"), "utf8");
|
|
28
|
+
const toolsContent = toolsTemplate.replace(
|
|
29
|
+
/\{\{SETUP_UI_URL\}\}/g,
|
|
30
|
+
resolveSetupUiUrl(baseUrl),
|
|
31
|
+
);
|
|
32
|
+
fs.writeFileSync(`${bootstrapDir}/TOOLS.md`, toolsContent);
|
|
33
|
+
console.log("[onboard] Bootstrap prompt files synced");
|
|
34
|
+
} catch (e) {
|
|
35
|
+
console.error("[onboard] Bootstrap prompt sync error:", e.message);
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const installControlUiSkill = ({ fs, openclawDir, baseUrl }) => {
|
|
40
|
+
try {
|
|
41
|
+
const skillDir = `${openclawDir}/skills/control-ui`;
|
|
42
|
+
fs.mkdirSync(skillDir, { recursive: true });
|
|
43
|
+
const skillTemplate = fs.readFileSync(path.join(kSetupDir, "skills", "control-ui", "SKILL.md"), "utf8");
|
|
44
|
+
const skillContent = skillTemplate.replace(/\{\{BASE_URL\}\}/g, baseUrl);
|
|
45
|
+
fs.writeFileSync(`${skillDir}/SKILL.md`, skillContent);
|
|
46
|
+
console.log(`[onboard] Control UI skill installed (${baseUrl})`);
|
|
47
|
+
} catch (e) {
|
|
48
|
+
console.error("[onboard] Skill install error:", e.message);
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
module.exports = { installControlUiSkill, syncBootstrapPromptFiles };
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
const { exec, execSync } = require("child_process");
|
|
2
|
+
const {
|
|
3
|
+
kVersionCacheTtlMs,
|
|
4
|
+
kLatestVersionCacheTtlMs,
|
|
5
|
+
kAppDir,
|
|
6
|
+
} = require("./constants");
|
|
7
|
+
const { normalizeOpenclawVersion } = require("./helpers");
|
|
8
|
+
|
|
9
|
+
const createOpenclawVersionService = ({ gatewayEnv, restartGateway, isOnboarded }) => {
|
|
10
|
+
let kOpenclawVersionCache = { value: null, fetchedAt: 0 };
|
|
11
|
+
let kOpenclawUpdateStatusCache = {
|
|
12
|
+
latestVersion: null,
|
|
13
|
+
hasUpdate: false,
|
|
14
|
+
fetchedAt: 0,
|
|
15
|
+
};
|
|
16
|
+
let kOpenclawUpdateInProgress = false;
|
|
17
|
+
|
|
18
|
+
const readOpenclawVersion = () => {
|
|
19
|
+
const now = Date.now();
|
|
20
|
+
if (
|
|
21
|
+
kOpenclawVersionCache.value &&
|
|
22
|
+
now - kOpenclawVersionCache.fetchedAt < kVersionCacheTtlMs
|
|
23
|
+
) {
|
|
24
|
+
return kOpenclawVersionCache.value;
|
|
25
|
+
}
|
|
26
|
+
try {
|
|
27
|
+
const raw = execSync("openclaw --version", {
|
|
28
|
+
env: gatewayEnv(),
|
|
29
|
+
timeout: 5000,
|
|
30
|
+
encoding: "utf8",
|
|
31
|
+
}).trim();
|
|
32
|
+
const version = normalizeOpenclawVersion(raw);
|
|
33
|
+
kOpenclawVersionCache = { value: version, fetchedAt: now };
|
|
34
|
+
return version;
|
|
35
|
+
} catch {
|
|
36
|
+
return kOpenclawVersionCache.value;
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const readOpenclawUpdateStatus = ({ refresh = false } = {}) => {
|
|
41
|
+
const now = Date.now();
|
|
42
|
+
if (
|
|
43
|
+
!refresh &&
|
|
44
|
+
kOpenclawUpdateStatusCache.fetchedAt &&
|
|
45
|
+
now - kOpenclawUpdateStatusCache.fetchedAt < kLatestVersionCacheTtlMs
|
|
46
|
+
) {
|
|
47
|
+
return {
|
|
48
|
+
latestVersion: kOpenclawUpdateStatusCache.latestVersion,
|
|
49
|
+
hasUpdate: kOpenclawUpdateStatusCache.hasUpdate,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
try {
|
|
53
|
+
console.log("[wrapper] Running: openclaw update status --json");
|
|
54
|
+
const raw = execSync("openclaw update status --json", {
|
|
55
|
+
env: gatewayEnv(),
|
|
56
|
+
timeout: 8000,
|
|
57
|
+
encoding: "utf8",
|
|
58
|
+
}).trim();
|
|
59
|
+
const parsed = JSON.parse(raw);
|
|
60
|
+
const latestVersion = normalizeOpenclawVersion(
|
|
61
|
+
parsed?.availability?.latestVersion || parsed?.update?.registry?.latestVersion,
|
|
62
|
+
);
|
|
63
|
+
const hasUpdate = !!parsed?.availability?.available;
|
|
64
|
+
kOpenclawUpdateStatusCache = {
|
|
65
|
+
latestVersion,
|
|
66
|
+
hasUpdate,
|
|
67
|
+
fetchedAt: now,
|
|
68
|
+
};
|
|
69
|
+
console.log(
|
|
70
|
+
`[wrapper] openclaw update status: hasUpdate=${hasUpdate} latest=${latestVersion || "unknown"}`,
|
|
71
|
+
);
|
|
72
|
+
return { latestVersion, hasUpdate };
|
|
73
|
+
} catch (err) {
|
|
74
|
+
console.log(
|
|
75
|
+
`[wrapper] openclaw update status error: ${(err.message || "unknown").slice(0, 200)}`,
|
|
76
|
+
);
|
|
77
|
+
throw new Error(err.message || "Failed to read OpenClaw update status");
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const installLatestOpenclaw = () =>
|
|
82
|
+
new Promise((resolve, reject) => {
|
|
83
|
+
console.log("[wrapper] Running: npm install --omit=dev --no-save --package-lock=false openclaw@latest");
|
|
84
|
+
exec(
|
|
85
|
+
"npm install --omit=dev --no-save --package-lock=false openclaw@latest",
|
|
86
|
+
{
|
|
87
|
+
cwd: kAppDir,
|
|
88
|
+
env: {
|
|
89
|
+
...process.env,
|
|
90
|
+
npm_config_update_notifier: "false",
|
|
91
|
+
npm_config_fund: "false",
|
|
92
|
+
npm_config_audit: "false",
|
|
93
|
+
},
|
|
94
|
+
timeout: 180000,
|
|
95
|
+
},
|
|
96
|
+
(err, stdout, stderr) => {
|
|
97
|
+
if (err) {
|
|
98
|
+
const message = String(stderr || err.message || "").trim();
|
|
99
|
+
console.log(`[wrapper] openclaw install error: ${message.slice(0, 200)}`);
|
|
100
|
+
return reject(new Error(message || "Failed to install openclaw@latest"));
|
|
101
|
+
}
|
|
102
|
+
if (stdout && stdout.trim()) {
|
|
103
|
+
console.log(`[wrapper] openclaw install stdout: ${stdout.trim().slice(0, 300)}`);
|
|
104
|
+
}
|
|
105
|
+
if (stderr && stderr.trim()) {
|
|
106
|
+
console.log(`[wrapper] openclaw install stderr: ${stderr.trim().slice(0, 300)}`);
|
|
107
|
+
}
|
|
108
|
+
console.log("[wrapper] openclaw install completed");
|
|
109
|
+
resolve({ stdout: stdout.trim(), stderr: stderr.trim() });
|
|
110
|
+
},
|
|
111
|
+
);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
const getVersionStatus = async (refresh) => {
|
|
115
|
+
const currentVersion = readOpenclawVersion();
|
|
116
|
+
try {
|
|
117
|
+
const { latestVersion, hasUpdate } = readOpenclawUpdateStatus({ refresh });
|
|
118
|
+
return { ok: true, currentVersion, latestVersion, hasUpdate };
|
|
119
|
+
} catch (err) {
|
|
120
|
+
return {
|
|
121
|
+
ok: false,
|
|
122
|
+
currentVersion,
|
|
123
|
+
latestVersion: kOpenclawUpdateStatusCache.latestVersion,
|
|
124
|
+
hasUpdate: kOpenclawUpdateStatusCache.hasUpdate,
|
|
125
|
+
error: err.message || "Failed to fetch latest OpenClaw version",
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const updateOpenclaw = async () => {
|
|
131
|
+
if (kOpenclawUpdateInProgress) {
|
|
132
|
+
return {
|
|
133
|
+
status: 409,
|
|
134
|
+
body: { ok: false, error: "OpenClaw update already in progress" },
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
kOpenclawUpdateInProgress = true;
|
|
139
|
+
const previousVersion = readOpenclawVersion();
|
|
140
|
+
try {
|
|
141
|
+
await installLatestOpenclaw();
|
|
142
|
+
kOpenclawVersionCache = { value: null, fetchedAt: 0 };
|
|
143
|
+
const currentVersion = readOpenclawVersion();
|
|
144
|
+
const { latestVersion, hasUpdate } = readOpenclawUpdateStatus({ refresh: true });
|
|
145
|
+
let restarted = false;
|
|
146
|
+
if (isOnboarded()) {
|
|
147
|
+
restartGateway();
|
|
148
|
+
restarted = true;
|
|
149
|
+
}
|
|
150
|
+
return {
|
|
151
|
+
status: 200,
|
|
152
|
+
body: {
|
|
153
|
+
ok: true,
|
|
154
|
+
previousVersion,
|
|
155
|
+
currentVersion,
|
|
156
|
+
latestVersion,
|
|
157
|
+
hasUpdate,
|
|
158
|
+
restarted,
|
|
159
|
+
updated: previousVersion !== currentVersion,
|
|
160
|
+
},
|
|
161
|
+
};
|
|
162
|
+
} catch (err) {
|
|
163
|
+
return {
|
|
164
|
+
status: 500,
|
|
165
|
+
body: { ok: false, error: err.message || "Failed to update OpenClaw" },
|
|
166
|
+
};
|
|
167
|
+
} finally {
|
|
168
|
+
kOpenclawUpdateInProgress = false;
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
return {
|
|
173
|
+
readOpenclawVersion,
|
|
174
|
+
getVersionStatus,
|
|
175
|
+
updateOpenclaw,
|
|
176
|
+
};
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
module.exports = { createOpenclawVersionService };
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
const crypto = require("crypto");
|
|
2
|
+
const path = require("path");
|
|
3
|
+
const { kLoginCleanupIntervalMs } = require("../constants");
|
|
4
|
+
|
|
5
|
+
const registerAuthRoutes = ({ app, loginThrottle }) => {
|
|
6
|
+
const SETUP_PASSWORD = process.env.SETUP_PASSWORD || "";
|
|
7
|
+
const kAuthTokens = new Set();
|
|
8
|
+
|
|
9
|
+
const cookieParser = (req) => {
|
|
10
|
+
const cookies = {};
|
|
11
|
+
(req.headers.cookie || "").split(";").forEach((c) => {
|
|
12
|
+
const [k, ...v] = c.trim().split("=");
|
|
13
|
+
if (k) cookies[k] = v.join("=");
|
|
14
|
+
});
|
|
15
|
+
return cookies;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
app.post("/api/auth/login", (req, res) => {
|
|
19
|
+
if (!SETUP_PASSWORD) return res.json({ ok: true });
|
|
20
|
+
const now = Date.now();
|
|
21
|
+
const clientKey = loginThrottle.getClientKey(req);
|
|
22
|
+
const state = loginThrottle.getOrCreateLoginAttemptState(clientKey, now);
|
|
23
|
+
const throttle = loginThrottle.evaluateLoginThrottle(state, now);
|
|
24
|
+
if (throttle.blocked) {
|
|
25
|
+
res.set("Retry-After", String(throttle.retryAfterSec));
|
|
26
|
+
return res.status(429).json({
|
|
27
|
+
ok: false,
|
|
28
|
+
error: "Too many attempts. Try again shortly.",
|
|
29
|
+
retryAfterSec: throttle.retryAfterSec,
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
if (req.body.password !== SETUP_PASSWORD) {
|
|
33
|
+
const failure = loginThrottle.recordLoginFailure(state, now);
|
|
34
|
+
if (failure.locked) {
|
|
35
|
+
const retryAfterSec = Math.max(1, Math.ceil(failure.lockMs / 1000));
|
|
36
|
+
res.set("Retry-After", String(retryAfterSec));
|
|
37
|
+
return res.status(429).json({
|
|
38
|
+
ok: false,
|
|
39
|
+
error: "Too many attempts. Try again shortly.",
|
|
40
|
+
retryAfterSec,
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
return res.status(401).json({ ok: false, error: "Invalid credentials" });
|
|
44
|
+
}
|
|
45
|
+
loginThrottle.recordLoginSuccess(clientKey);
|
|
46
|
+
const token = crypto.randomBytes(32).toString("hex");
|
|
47
|
+
kAuthTokens.add(token);
|
|
48
|
+
res.cookie("setup_token", token, {
|
|
49
|
+
httpOnly: true,
|
|
50
|
+
sameSite: "lax",
|
|
51
|
+
path: "/",
|
|
52
|
+
});
|
|
53
|
+
res.json({ ok: true });
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
setInterval(() => {
|
|
57
|
+
loginThrottle.cleanupLoginAttemptStates();
|
|
58
|
+
}, kLoginCleanupIntervalMs).unref();
|
|
59
|
+
|
|
60
|
+
const requireAuth = (req, res, next) => {
|
|
61
|
+
if (!SETUP_PASSWORD) return next();
|
|
62
|
+
if (req.path.startsWith("/auth/google/callback")) return next();
|
|
63
|
+
if (req.path.startsWith("/auth/codex/callback")) return next();
|
|
64
|
+
const cookies = cookieParser(req);
|
|
65
|
+
const token = cookies.setup_token || req.query.token;
|
|
66
|
+
if (token && kAuthTokens.has(token)) return next();
|
|
67
|
+
if (req.path.startsWith("/api/")) {
|
|
68
|
+
return res.status(401).json({ error: "Unauthorized" });
|
|
69
|
+
}
|
|
70
|
+
return res.sendFile(path.join(__dirname, "..", "..", "public", "login.html"));
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
app.use("/setup", requireAuth);
|
|
74
|
+
app.use("/api", requireAuth);
|
|
75
|
+
app.use("/auth", requireAuth);
|
|
76
|
+
|
|
77
|
+
return { requireAuth };
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
module.exports = { registerAuthRoutes };
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
const crypto = require("crypto");
|
|
2
|
+
const {
|
|
3
|
+
CODEX_OAUTH_REDIRECT_URI,
|
|
4
|
+
CODEX_OAUTH_AUTHORIZE_URL,
|
|
5
|
+
CODEX_OAUTH_CLIENT_ID,
|
|
6
|
+
CODEX_OAUTH_SCOPE,
|
|
7
|
+
CODEX_OAUTH_TOKEN_URL,
|
|
8
|
+
kCodexOauthStateTtlMs,
|
|
9
|
+
} = require("../constants");
|
|
10
|
+
|
|
11
|
+
const createCodexOauthState = () => {
|
|
12
|
+
const kCodexOauthStates = new Map();
|
|
13
|
+
|
|
14
|
+
const cleanupCodexOauthStates = () => {
|
|
15
|
+
const now = Date.now();
|
|
16
|
+
for (const [state, value] of kCodexOauthStates.entries()) {
|
|
17
|
+
if (!value || now - value.createdAt > kCodexOauthStateTtlMs) {
|
|
18
|
+
kCodexOauthStates.delete(state);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
return { kCodexOauthStates, cleanupCodexOauthStates };
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const registerCodexRoutes = ({
|
|
27
|
+
app,
|
|
28
|
+
createPkcePair,
|
|
29
|
+
parseCodexAuthorizationInput,
|
|
30
|
+
getCodexAccountId,
|
|
31
|
+
authProfiles,
|
|
32
|
+
}) => {
|
|
33
|
+
const { kCodexOauthStates, cleanupCodexOauthStates } = createCodexOauthState();
|
|
34
|
+
|
|
35
|
+
app.get("/api/codex/status", (req, res) => {
|
|
36
|
+
const profile = authProfiles.getCodexProfile();
|
|
37
|
+
if (!profile) return res.json({ connected: false });
|
|
38
|
+
res.json({
|
|
39
|
+
connected: true,
|
|
40
|
+
profileId: profile.profileId,
|
|
41
|
+
accountId: profile.accountId || null,
|
|
42
|
+
expires: typeof profile.expires === "number" ? profile.expires : null,
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
app.get("/auth/codex/start", (req, res) => {
|
|
47
|
+
try {
|
|
48
|
+
cleanupCodexOauthStates();
|
|
49
|
+
const redirectUri = CODEX_OAUTH_REDIRECT_URI;
|
|
50
|
+
const { verifier, challenge } = createPkcePair();
|
|
51
|
+
const state = crypto.randomBytes(16).toString("hex");
|
|
52
|
+
kCodexOauthStates.set(state, { verifier, redirectUri, createdAt: Date.now() });
|
|
53
|
+
|
|
54
|
+
const authUrl = new URL(CODEX_OAUTH_AUTHORIZE_URL);
|
|
55
|
+
authUrl.searchParams.set("response_type", "code");
|
|
56
|
+
authUrl.searchParams.set("client_id", CODEX_OAUTH_CLIENT_ID);
|
|
57
|
+
authUrl.searchParams.set("redirect_uri", redirectUri);
|
|
58
|
+
authUrl.searchParams.set("scope", CODEX_OAUTH_SCOPE);
|
|
59
|
+
authUrl.searchParams.set("code_challenge", challenge);
|
|
60
|
+
authUrl.searchParams.set("code_challenge_method", "S256");
|
|
61
|
+
authUrl.searchParams.set("state", state);
|
|
62
|
+
authUrl.searchParams.set("id_token_add_organizations", "true");
|
|
63
|
+
authUrl.searchParams.set("codex_cli_simplified_flow", "true");
|
|
64
|
+
// Keep this aligned with OpenClaw's own Codex OAuth flow.
|
|
65
|
+
authUrl.searchParams.set("originator", "pi");
|
|
66
|
+
res.redirect(authUrl.toString());
|
|
67
|
+
} catch (err) {
|
|
68
|
+
console.error("[codex] Failed to start OAuth flow:", err);
|
|
69
|
+
res.redirect("/setup?codex=error&message=" + encodeURIComponent(err.message));
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
app.get("/auth/codex/callback", async (req, res) => {
|
|
74
|
+
const { code, error, state } = req.query;
|
|
75
|
+
if (error) {
|
|
76
|
+
return res.send(`<!DOCTYPE html><html><body><script>
|
|
77
|
+
window.opener?.postMessage({ codex: 'error', message: '${String(error).replace(/'/g, "\\'")}' }, '*');
|
|
78
|
+
window.close();
|
|
79
|
+
</script><p>Codex auth failed. You can close this window.</p></body></html>`);
|
|
80
|
+
}
|
|
81
|
+
if (!code || !state) {
|
|
82
|
+
return res.send(`<!DOCTYPE html><html><body><script>
|
|
83
|
+
window.opener?.postMessage({ codex: 'error', message: 'Missing OAuth state/code' }, '*');
|
|
84
|
+
window.close();
|
|
85
|
+
</script><p>Missing OAuth state/code. You can close this window.</p></body></html>`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
cleanupCodexOauthStates();
|
|
89
|
+
const oauthState = kCodexOauthStates.get(String(state));
|
|
90
|
+
kCodexOauthStates.delete(String(state));
|
|
91
|
+
if (!oauthState) {
|
|
92
|
+
return res.send(`<!DOCTYPE html><html><body><script>
|
|
93
|
+
window.opener?.postMessage({ codex: 'error', message: 'State mismatch or expired login attempt' }, '*');
|
|
94
|
+
window.close();
|
|
95
|
+
</script><p>State mismatch. You can close this window.</p></body></html>`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
try {
|
|
99
|
+
const tokenRes = await fetch(CODEX_OAUTH_TOKEN_URL, {
|
|
100
|
+
method: "POST",
|
|
101
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
102
|
+
body: new URLSearchParams({
|
|
103
|
+
grant_type: "authorization_code",
|
|
104
|
+
client_id: CODEX_OAUTH_CLIENT_ID,
|
|
105
|
+
code: String(code),
|
|
106
|
+
code_verifier: oauthState.verifier,
|
|
107
|
+
redirect_uri: oauthState.redirectUri,
|
|
108
|
+
}),
|
|
109
|
+
});
|
|
110
|
+
const json = await tokenRes.json().catch(() => ({}));
|
|
111
|
+
if (
|
|
112
|
+
!tokenRes.ok ||
|
|
113
|
+
!json.access_token ||
|
|
114
|
+
!json.refresh_token ||
|
|
115
|
+
typeof json.expires_in !== "number"
|
|
116
|
+
) {
|
|
117
|
+
throw new Error(`Token exchange failed (${tokenRes.status})`);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const access = String(json.access_token);
|
|
121
|
+
const refresh = String(json.refresh_token);
|
|
122
|
+
const expires = Date.now() + Number(json.expires_in) * 1000;
|
|
123
|
+
const accountId = getCodexAccountId(access);
|
|
124
|
+
|
|
125
|
+
authProfiles.upsertCodexProfile({ access, refresh, expires, accountId });
|
|
126
|
+
|
|
127
|
+
return res.send(`<!DOCTYPE html><html><body><script>
|
|
128
|
+
window.opener?.postMessage({ codex: 'success' }, '*');
|
|
129
|
+
window.close();
|
|
130
|
+
</script><p>Codex connected. You can close this window.</p></body></html>`);
|
|
131
|
+
} catch (err) {
|
|
132
|
+
console.error("[codex] OAuth callback error:", err);
|
|
133
|
+
return res.send(`<!DOCTYPE html><html><body><script>
|
|
134
|
+
window.opener?.postMessage({ codex: 'error', message: '${String(err.message || "OAuth error").replace(/'/g, "\\'")}' }, '*');
|
|
135
|
+
window.close();
|
|
136
|
+
</script><p>Error: ${String(err.message || "OAuth error")}. You can close this window.</p></body></html>`);
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
app.post("/api/codex/exchange", async (req, res) => {
|
|
141
|
+
try {
|
|
142
|
+
cleanupCodexOauthStates();
|
|
143
|
+
const { input } = req.body || {};
|
|
144
|
+
const parsed = parseCodexAuthorizationInput(input);
|
|
145
|
+
const code = String(parsed.code || "");
|
|
146
|
+
const state = String(parsed.state || "");
|
|
147
|
+
if (!code || !state) {
|
|
148
|
+
return res.status(400).json({
|
|
149
|
+
ok: false,
|
|
150
|
+
error: "Missing code/state. Paste the full redirect URL from your browser address bar.",
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
const oauthState = kCodexOauthStates.get(state);
|
|
154
|
+
if (!oauthState) {
|
|
155
|
+
return res.status(400).json({
|
|
156
|
+
ok: false,
|
|
157
|
+
error: "OAuth state expired or invalid. Start Codex OAuth again.",
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
kCodexOauthStates.delete(state);
|
|
161
|
+
const tokenRes = await fetch(CODEX_OAUTH_TOKEN_URL, {
|
|
162
|
+
method: "POST",
|
|
163
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
164
|
+
body: new URLSearchParams({
|
|
165
|
+
grant_type: "authorization_code",
|
|
166
|
+
client_id: CODEX_OAUTH_CLIENT_ID,
|
|
167
|
+
code,
|
|
168
|
+
code_verifier: oauthState.verifier,
|
|
169
|
+
redirect_uri: oauthState.redirectUri,
|
|
170
|
+
}),
|
|
171
|
+
});
|
|
172
|
+
const json = await tokenRes.json().catch(() => ({}));
|
|
173
|
+
if (
|
|
174
|
+
!tokenRes.ok ||
|
|
175
|
+
!json.access_token ||
|
|
176
|
+
!json.refresh_token ||
|
|
177
|
+
typeof json.expires_in !== "number"
|
|
178
|
+
) {
|
|
179
|
+
return res.status(400).json({
|
|
180
|
+
ok: false,
|
|
181
|
+
error: `Token exchange failed (${tokenRes.status})`,
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
const access = String(json.access_token);
|
|
185
|
+
const refresh = String(json.refresh_token);
|
|
186
|
+
const expires = Date.now() + Number(json.expires_in) * 1000;
|
|
187
|
+
const accountId = getCodexAccountId(access);
|
|
188
|
+
authProfiles.upsertCodexProfile({ access, refresh, expires, accountId });
|
|
189
|
+
return res.json({ ok: true });
|
|
190
|
+
} catch (err) {
|
|
191
|
+
console.error("[codex] Manual exchange error:", err);
|
|
192
|
+
return res
|
|
193
|
+
.status(500)
|
|
194
|
+
.json({ ok: false, error: err.message || "Codex OAuth exchange failed" });
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
app.post("/api/codex/disconnect", (req, res) => {
|
|
199
|
+
const changed = authProfiles.removeCodexProfiles();
|
|
200
|
+
res.json({ ok: true, changed });
|
|
201
|
+
});
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
module.exports = { registerCodexRoutes };
|