@tarunspandit/codexflow 0.29.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/AGENTS.example.md +18 -0
- package/CHANGELOG.md +311 -0
- package/CHATGPT_PROMPT.md +12 -0
- package/CODEX_PROMPT.md +12 -0
- package/CONTRIBUTING.md +51 -0
- package/DOMAIN_SETUP.md +241 -0
- package/FAQ.md +327 -0
- package/FAQ_ZH.md +279 -0
- package/LICENSE +21 -0
- package/NOTICE +9 -0
- package/PUBLIC_LAUNCH_CHECKLIST.md +111 -0
- package/README.md +287 -0
- package/README_ZH.md +461 -0
- package/SECURITY.md +145 -0
- package/config.example.env +31 -0
- package/dist/analysis/cache.js +27 -0
- package/dist/analysis/cache.js.map +1 -0
- package/dist/analysis/classify.js +102 -0
- package/dist/analysis/classify.js.map +1 -0
- package/dist/analysis/extract.js +139 -0
- package/dist/analysis/extract.js.map +1 -0
- package/dist/analysis/graph.js +22 -0
- package/dist/analysis/graph.js.map +1 -0
- package/dist/analysis/impact.js +167 -0
- package/dist/analysis/impact.js.map +1 -0
- package/dist/analysis/index.js +215 -0
- package/dist/analysis/index.js.map +1 -0
- package/dist/analysis/inventory.js +51 -0
- package/dist/analysis/inventory.js.map +1 -0
- package/dist/analysis/providers.js +24 -0
- package/dist/analysis/providers.js.map +1 -0
- package/dist/analysis/rank.js +27 -0
- package/dist/analysis/rank.js.map +1 -0
- package/dist/analysis/types.js +8 -0
- package/dist/analysis/types.js.map +1 -0
- package/dist/bashOps.js +233 -0
- package/dist/bashOps.js.map +1 -0
- package/dist/capabilitiesOps.js +365 -0
- package/dist/capabilitiesOps.js.map +1 -0
- package/dist/codexSessions.js +379 -0
- package/dist/codexSessions.js.map +1 -0
- package/dist/config.js +289 -0
- package/dist/config.js.map +1 -0
- package/dist/fsOps.js +286 -0
- package/dist/fsOps.js.map +1 -0
- package/dist/gitOps.js +79 -0
- package/dist/gitOps.js.map +1 -0
- package/dist/guard.js +198 -0
- package/dist/guard.js.map +1 -0
- package/dist/http.js +1671 -0
- package/dist/http.js.map +1 -0
- package/dist/proContext.js +274 -0
- package/dist/proContext.js.map +1 -0
- package/dist/profileStore.js +89 -0
- package/dist/profileStore.js.map +1 -0
- package/dist/projectCatalog.js +134 -0
- package/dist/projectCatalog.js.map +1 -0
- package/dist/redact.js +73 -0
- package/dist/redact.js.map +1 -0
- package/dist/searchOps.js +186 -0
- package/dist/searchOps.js.map +1 -0
- package/dist/server.js +2502 -0
- package/dist/server.js.map +1 -0
- package/dist/stdio.js +36 -0
- package/dist/stdio.js.map +1 -0
- package/dist/toolCardWidget.js +1155 -0
- package/dist/toolCardWidget.js.map +1 -0
- package/dist/workspaceOps.js +229 -0
- package/dist/workspaceOps.js.map +1 -0
- package/docs/.nojekyll +1 -0
- package/docs/favicon.svg +5 -0
- package/docs/index.html +638 -0
- package/docs/og.png +0 -0
- package/docs/script.js +80 -0
- package/docs/star.svg +11 -0
- package/docs/styles.css +1229 -0
- package/docs/zh.html +436 -0
- package/package.json +94 -0
- package/scripts/analysis-cli-smoke.mjs +81 -0
- package/scripts/analysis-smoke.mjs +179 -0
- package/scripts/clean.mjs +6 -0
- package/scripts/cli-smoke.mjs +168 -0
- package/scripts/codexflow.mjs +4375 -0
- package/scripts/doctor-smoke.mjs +90 -0
- package/scripts/execute-handoff-smoke.mjs +1110 -0
- package/scripts/http-smoke.mjs +812 -0
- package/scripts/pro-apply.mjs +141 -0
- package/scripts/pro-bundle.mjs +121 -0
- package/scripts/pro-smoke.mjs +95 -0
- package/scripts/settings-smoke.mjs +756 -0
- package/scripts/smoke.mjs +1194 -0
- package/scripts/stress.mjs +835 -0
package/dist/http.js
ADDED
|
@@ -0,0 +1,1671 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { timingSafeEqual } from "node:crypto";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import express from "express";
|
|
6
|
+
import cors from "cors";
|
|
7
|
+
import { z } from "zod";
|
|
8
|
+
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
9
|
+
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
|
|
10
|
+
import { expandHome, loadConfig } from "./config.js";
|
|
11
|
+
import { profilePathForRoot, readRuntimeConnection, readWorkspaceProfile, sanitizeWorkspaceProfile, saveWorkspaceProfile } from "./profileStore.js";
|
|
12
|
+
import { redactSensitiveText, redactStructured } from "./redact.js";
|
|
13
|
+
import { createCodexFlowServer } from "./server.js";
|
|
14
|
+
function escapeHtml(value) {
|
|
15
|
+
return String(value ?? "")
|
|
16
|
+
.replaceAll("&", "&")
|
|
17
|
+
.replaceAll("<", "<")
|
|
18
|
+
.replaceAll(">", ">")
|
|
19
|
+
.replaceAll('"', """);
|
|
20
|
+
}
|
|
21
|
+
function shellQuote(value) {
|
|
22
|
+
return `'${value.replaceAll("'", "'\\''")}'`;
|
|
23
|
+
}
|
|
24
|
+
function copyCommand(title, description, command, displayCommand = command, copyKind = "") {
|
|
25
|
+
const copyAttrs = copyKind
|
|
26
|
+
? `data-copy-kind="${escapeHtml(copyKind)}" data-copy-base="${escapeHtml(command)}"`
|
|
27
|
+
: `data-copy="${escapeHtml(command)}"`;
|
|
28
|
+
return `<div class="control">
|
|
29
|
+
<div>
|
|
30
|
+
<strong>${escapeHtml(title)}</strong>
|
|
31
|
+
<p>${escapeHtml(description)}</p>
|
|
32
|
+
<code>${escapeHtml(displayCommand)}</code>
|
|
33
|
+
</div>
|
|
34
|
+
<button type="button" class="copy-mini" ${copyAttrs}>Copy</button>
|
|
35
|
+
</div>`;
|
|
36
|
+
}
|
|
37
|
+
const TUNNELS = ["cloudflare", "ngrok", "cloudflare-named", "tailscale", "none"];
|
|
38
|
+
const MODES = ["agent", "handoff", "pro"];
|
|
39
|
+
const BASH_MODES = ["safe", "off", "full"];
|
|
40
|
+
const BASH_TRANSCRIPTS = ["compact", "full"];
|
|
41
|
+
const CODEX_SESSIONS = ["off", "metadata", "read"];
|
|
42
|
+
const WRITE_MODES = ["workspace", "handoff", "off"];
|
|
43
|
+
const TOOL_MODES = ["standard", "minimal", "full"];
|
|
44
|
+
const textField = (max) => z.preprocess((value) => (typeof value === "string" ? value.trim() : value), z.string().max(max).optional());
|
|
45
|
+
const AdminProfilePatch = z.object({
|
|
46
|
+
tunnel: z.enum(TUNNELS).optional(),
|
|
47
|
+
hostname: textField(253),
|
|
48
|
+
port: z.coerce.number().int().min(1).max(65535).optional(),
|
|
49
|
+
mode: z.enum(MODES).optional(),
|
|
50
|
+
bash: z.enum(BASH_MODES).optional(),
|
|
51
|
+
bashTranscript: z.enum(BASH_TRANSCRIPTS).optional(),
|
|
52
|
+
codexSessions: z.enum(CODEX_SESSIONS).optional(),
|
|
53
|
+
codexDir: textField(4096),
|
|
54
|
+
bashSession: textField(64).refine((value) => !value || /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(value), "bashSession must be 1-64 characters using letters, numbers, dot, underscore, or dash, and must start with a letter or number."),
|
|
55
|
+
requireBashSession: z.boolean().optional(),
|
|
56
|
+
write: z.enum(WRITE_MODES).optional(),
|
|
57
|
+
toolMode: z.enum(TOOL_MODES).optional(),
|
|
58
|
+
toolCards: z.boolean().optional(),
|
|
59
|
+
widgetDomain: textField(2048),
|
|
60
|
+
tunnelName: textField(128),
|
|
61
|
+
ngrokConfig: textField(4096),
|
|
62
|
+
cloudflareConfig: textField(4096),
|
|
63
|
+
cloudflareTokenFile: textField(4096),
|
|
64
|
+
noInstallCloudflared: z.boolean().optional()
|
|
65
|
+
}).strict();
|
|
66
|
+
function oneOf(value, values, fallback) {
|
|
67
|
+
return typeof value === "string" && values.includes(value) ? value : fallback;
|
|
68
|
+
}
|
|
69
|
+
function runtimeTunnelFallback() {
|
|
70
|
+
if (process.env.CODEXFLOW_TUNNEL && TUNNELS.includes(process.env.CODEXFLOW_TUNNEL)) {
|
|
71
|
+
return process.env.CODEXFLOW_TUNNEL;
|
|
72
|
+
}
|
|
73
|
+
return process.env.CODEXFLOW_TUNNEL_MODE === "0" ? "none" : "cloudflare";
|
|
74
|
+
}
|
|
75
|
+
function normalizePublicHostname(value) {
|
|
76
|
+
const raw = value?.trim().replace(/\/+$/, "") ?? "";
|
|
77
|
+
if (!raw)
|
|
78
|
+
return "";
|
|
79
|
+
const url = new URL(raw.includes("://") ? raw : `https://${raw}`);
|
|
80
|
+
if (url.protocol !== "https:")
|
|
81
|
+
throw new Error("hostname must use https when a scheme is provided.");
|
|
82
|
+
if (url.search || url.hash)
|
|
83
|
+
throw new Error("hostname must not include query strings or fragments.");
|
|
84
|
+
if (url.pathname !== "/" && url.pathname !== "/mcp")
|
|
85
|
+
throw new Error("hostname must be a host, URL root, or /mcp URL.");
|
|
86
|
+
return url.host;
|
|
87
|
+
}
|
|
88
|
+
function normalizeWidgetDomain(value) {
|
|
89
|
+
const raw = value?.trim() ?? "";
|
|
90
|
+
if (!raw)
|
|
91
|
+
return "";
|
|
92
|
+
const url = new URL(raw);
|
|
93
|
+
if (url.protocol !== "https:")
|
|
94
|
+
throw new Error("widgetDomain must use https.");
|
|
95
|
+
if (url.pathname !== "/" || url.search || url.hash) {
|
|
96
|
+
throw new Error("widgetDomain must be an origin only, for example https://widgets.example.com.");
|
|
97
|
+
}
|
|
98
|
+
return url.origin;
|
|
99
|
+
}
|
|
100
|
+
function effectiveWriteMode(mode, write) {
|
|
101
|
+
if (mode === "agent")
|
|
102
|
+
return write;
|
|
103
|
+
return write === "off" ? "off" : "handoff";
|
|
104
|
+
}
|
|
105
|
+
function normalizeProfilePath(root, value) {
|
|
106
|
+
const raw = value?.trim() ?? "";
|
|
107
|
+
if (!raw)
|
|
108
|
+
return "";
|
|
109
|
+
const expanded = expandHome(raw);
|
|
110
|
+
return path.isAbsolute(expanded) || path.win32.isAbsolute(expanded)
|
|
111
|
+
? path.resolve(expanded)
|
|
112
|
+
: path.resolve(root, expanded);
|
|
113
|
+
}
|
|
114
|
+
function profileValues(config, profile = readWorkspaceProfile(config.defaultRoot)) {
|
|
115
|
+
const hostname = profile.hostname ??
|
|
116
|
+
process.env.CODEXFLOW_PUBLIC_HOSTNAME ??
|
|
117
|
+
process.env.CODEXFLOW_HOSTNAME ??
|
|
118
|
+
process.env.NGROK_DOMAIN ??
|
|
119
|
+
"";
|
|
120
|
+
const mode = oneOf(profile.mode ?? process.env.CODEXFLOW_MODE, MODES, "agent");
|
|
121
|
+
const write = effectiveWriteMode(mode, oneOf(profile.write ?? config.writeMode, WRITE_MODES, config.writeMode));
|
|
122
|
+
return {
|
|
123
|
+
port: String(profile.port ?? config.port),
|
|
124
|
+
mode,
|
|
125
|
+
tunnel: oneOf(profile.tunnel, TUNNELS, runtimeTunnelFallback()),
|
|
126
|
+
hostname: String(hostname),
|
|
127
|
+
tunnelName: String(profile.tunnelName ?? ""),
|
|
128
|
+
ngrokConfig: String(profile.ngrokConfig ?? ""),
|
|
129
|
+
cloudflareConfig: String(profile.cloudflareConfig ?? ""),
|
|
130
|
+
cloudflareTokenFile: String(profile.cloudflareTokenFile ?? ""),
|
|
131
|
+
bash: oneOf(profile.bash ?? config.bashMode, BASH_MODES, config.bashMode),
|
|
132
|
+
bashTranscript: oneOf(profile.bashTranscript ?? config.bashTranscript, BASH_TRANSCRIPTS, config.bashTranscript),
|
|
133
|
+
codexSessions: oneOf(profile.codexSessions ?? config.codexSessions, CODEX_SESSIONS, config.codexSessions),
|
|
134
|
+
codexDir: String(profile.codexDir ?? config.codexDir),
|
|
135
|
+
bashSession: String(profile.bashSession ?? config.bashSessionId ?? ""),
|
|
136
|
+
requireBashSession: Boolean(profile.requireBashSession ?? config.requireBashSession),
|
|
137
|
+
write,
|
|
138
|
+
toolMode: oneOf(profile.toolMode ?? config.toolMode, TOOL_MODES, config.toolMode),
|
|
139
|
+
toolCards: Boolean(profile.toolCards ?? config.toolCards),
|
|
140
|
+
widgetDomain: String(profile.widgetDomain ?? config.widgetDomain),
|
|
141
|
+
noInstallCloudflared: Boolean(profile.noInstallCloudflared)
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
const OPTION_LABELS = {
|
|
145
|
+
cloudflare: "Cloudflare quick tunnel",
|
|
146
|
+
ngrok: "ngrok stable URL",
|
|
147
|
+
"cloudflare-named": "Cloudflare named tunnel",
|
|
148
|
+
tailscale: "Tailscale Funnel",
|
|
149
|
+
none: "Local only",
|
|
150
|
+
agent: "Agent",
|
|
151
|
+
handoff: "Handoff",
|
|
152
|
+
pro: "Pro bundle",
|
|
153
|
+
safe: "Safe",
|
|
154
|
+
off: "Off",
|
|
155
|
+
full: "Full",
|
|
156
|
+
compact: "Compact",
|
|
157
|
+
metadata: "Metadata",
|
|
158
|
+
read: "Read",
|
|
159
|
+
workspace: "Workspace",
|
|
160
|
+
minimal: "Minimal",
|
|
161
|
+
standard: "Standard"
|
|
162
|
+
};
|
|
163
|
+
function optionLabel(value) {
|
|
164
|
+
return OPTION_LABELS[value] ?? value;
|
|
165
|
+
}
|
|
166
|
+
function selectOptions(values, current) {
|
|
167
|
+
return values
|
|
168
|
+
.map((value) => `<option value="${escapeHtml(value)}"${value === current ? " selected" : ""}>${escapeHtml(optionLabel(value))}</option>`)
|
|
169
|
+
.join("");
|
|
170
|
+
}
|
|
171
|
+
function serverUrlDisplay(endpoint, authEnabled) {
|
|
172
|
+
if (!endpoint)
|
|
173
|
+
return "";
|
|
174
|
+
const safeEndpoint = redactSensitiveText(endpoint);
|
|
175
|
+
if (!authEnabled)
|
|
176
|
+
return safeEndpoint;
|
|
177
|
+
const glue = safeEndpoint.includes("?") ? "&" : "?";
|
|
178
|
+
return `${safeEndpoint}${glue}codexflow_token=<redacted>`;
|
|
179
|
+
}
|
|
180
|
+
function currentTunnelMessage(tunnel, endpoint) {
|
|
181
|
+
if (endpoint) {
|
|
182
|
+
if (tunnel === "cloudflare")
|
|
183
|
+
return "Cloudflare generated this URL for the current run. Quick tunnel URLs change after restart.";
|
|
184
|
+
if (tunnel === "ngrok")
|
|
185
|
+
return "ngrok is using the saved public hostname for this run.";
|
|
186
|
+
if (tunnel === "cloudflare-named")
|
|
187
|
+
return "Cloudflare named tunnel is using the saved public hostname for this run.";
|
|
188
|
+
if (tunnel === "tailscale")
|
|
189
|
+
return "Tailscale Funnel is using the saved ts.net hostname for this run.";
|
|
190
|
+
return "Local-only endpoint for clients that can reach this machine.";
|
|
191
|
+
}
|
|
192
|
+
if (tunnel === "cloudflare")
|
|
193
|
+
return "Cloudflare quick tunnels print a generated URL after the tunnel opens.";
|
|
194
|
+
if (tunnel === "ngrok")
|
|
195
|
+
return "Enter your reserved ngrok domain, or set NGROK_DOMAIN before starting CodexFlow.";
|
|
196
|
+
if (tunnel === "cloudflare-named")
|
|
197
|
+
return "Enter the Cloudflare hostname routed to your named tunnel.";
|
|
198
|
+
if (tunnel === "tailscale")
|
|
199
|
+
return "Enter the Tailscale Funnel hostname for this device, for example machine.tailnet.ts.net.";
|
|
200
|
+
return "No public tunnel is saved; local MCP clients can use the local URL.";
|
|
201
|
+
}
|
|
202
|
+
function profileForm(config) {
|
|
203
|
+
const profile = readWorkspaceProfile(config.defaultRoot);
|
|
204
|
+
const values = profileValues(config, profile);
|
|
205
|
+
const runtime = readRuntimeConnection(config.defaultRoot);
|
|
206
|
+
const profilePath = profile.profilePath ?? profilePathForRoot(config.defaultRoot);
|
|
207
|
+
const savedLabel = profile.profilePath ? "saved" : "not saved yet";
|
|
208
|
+
const runtimeEndpoint = typeof runtime.endpoint === "string" ? runtime.endpoint : "";
|
|
209
|
+
const runtimeTunnel = oneOf(runtime.tunnel ?? values.tunnel, TUNNELS, values.tunnel);
|
|
210
|
+
const runtimeUrl = serverUrlDisplay(runtimeEndpoint, Boolean(config.authToken));
|
|
211
|
+
const savedEndpoint = values.hostname ? `https://${values.hostname}/mcp` : "";
|
|
212
|
+
const savedUrl = serverUrlDisplay(savedEndpoint, Boolean(config.authToken));
|
|
213
|
+
const ngrokHostname = process.env.NGROK_DOMAIN ?? (values.tunnel === "ngrok" ? values.hostname : "");
|
|
214
|
+
const cloudflareHostname = process.env.CODEXFLOW_PUBLIC_HOSTNAME ??
|
|
215
|
+
process.env.CODEXFLOW_HOSTNAME ??
|
|
216
|
+
(values.tunnel === "cloudflare-named" ? values.hostname : "");
|
|
217
|
+
const currentUrlBlock = runtimeUrl
|
|
218
|
+
? `<div class="current-url">
|
|
219
|
+
<div>
|
|
220
|
+
<span>Current Server URL</span>
|
|
221
|
+
<code>${escapeHtml(runtimeUrl)}</code>
|
|
222
|
+
<p>${escapeHtml(currentTunnelMessage(runtimeTunnel, runtimeEndpoint))}</p>
|
|
223
|
+
</div>
|
|
224
|
+
<button type="button" class="copy-mini" data-copy-kind="server-url" data-copy-base="${escapeHtml(redactSensitiveText(runtimeEndpoint))}">Copy</button>
|
|
225
|
+
</div>`
|
|
226
|
+
: `<div class="current-url idle">
|
|
227
|
+
<div>
|
|
228
|
+
<span>${savedUrl ? "Saved Server URL preview" : "Current Server URL"}</span>
|
|
229
|
+
<code>${savedUrl ? escapeHtml(savedUrl) : "No public URL detected for this run"}</code>
|
|
230
|
+
<p>${escapeHtml(savedUrl ? "This is based on the saved hostname. It becomes current after the launcher starts that tunnel." : currentTunnelMessage(values.tunnel, ""))}</p>
|
|
231
|
+
</div>
|
|
232
|
+
${savedEndpoint ? `<button type="button" class="copy-mini" data-copy-kind="server-url" data-copy-base="${escapeHtml(redactSensitiveText(savedEndpoint))}">Copy</button>` : ""}
|
|
233
|
+
</div>`;
|
|
234
|
+
return `<section class="panel profile-panel" id="profile">
|
|
235
|
+
<div class="section-head">
|
|
236
|
+
<div>
|
|
237
|
+
<h2>Connection profile</h2>
|
|
238
|
+
<p>Use this for the next start. Current tunnel URLs appear here when the launcher knows them.</p>
|
|
239
|
+
</div>
|
|
240
|
+
<span class="pill ${profile.profilePath ? "" : "warn"}">${escapeHtml(savedLabel)}</span>
|
|
241
|
+
</div>
|
|
242
|
+
<form class="profile-form" data-profile-form>
|
|
243
|
+
${currentUrlBlock}
|
|
244
|
+
<fieldset class="profile-group">
|
|
245
|
+
<legend>Connection</legend>
|
|
246
|
+
<p>Choose how ChatGPT reaches this local MCP server. Stable providers use the saved hostname; quick Cloudflare generates the URL at launch.</p>
|
|
247
|
+
<div class="form-grid">
|
|
248
|
+
<label><span>Tunnel</span><select name="tunnel" data-tunnel-select data-ngrok-hostname="${escapeHtml(ngrokHostname)}" data-cloudflare-hostname="${escapeHtml(cloudflareHostname)}">${selectOptions(TUNNELS, values.tunnel)}</select></label>
|
|
249
|
+
<label><span>Public hostname</span><input name="hostname" value="${escapeHtml(values.hostname)}" data-hostname-input data-autofilled="0"></label>
|
|
250
|
+
<label><span>Port</span><input name="port" type="number" min="1" max="65535" value="${escapeHtml(values.port)}"></label>
|
|
251
|
+
<label><span>Mode</span><select name="mode">${selectOptions(MODES, values.mode)}</select></label>
|
|
252
|
+
<label><span>Cloudflare tunnel name</span><input name="tunnelName" value="${escapeHtml(values.tunnelName)}"></label>
|
|
253
|
+
<label><span>ngrok config file</span><input name="ngrokConfig" value="${escapeHtml(values.ngrokConfig)}"></label>
|
|
254
|
+
<label><span>Cloudflare config file</span><input name="cloudflareConfig" value="${escapeHtml(values.cloudflareConfig)}"></label>
|
|
255
|
+
<label><span>Cloudflare token file</span><input name="cloudflareTokenFile" value="${escapeHtml(values.cloudflareTokenFile)}"></label>
|
|
256
|
+
</div>
|
|
257
|
+
<p class="field-help" data-hostname-help>${escapeHtml(currentTunnelMessage(values.tunnel, runtimeEndpoint))}</p>
|
|
258
|
+
<label class="check-row"><input name="noInstallCloudflared" type="checkbox" value="true"${values.noInstallCloudflared ? " checked" : ""}><span>Do not auto-install cloudflared</span></label>
|
|
259
|
+
</fieldset>
|
|
260
|
+
<fieldset class="profile-group">
|
|
261
|
+
<legend>Runtime policy</legend>
|
|
262
|
+
<p>Save the default access level for the next launch. These settings do not mutate the process that is already running.</p>
|
|
263
|
+
<div class="form-grid">
|
|
264
|
+
<label><span>Bash</span><select name="bash">${selectOptions(BASH_MODES, values.bash)}</select></label>
|
|
265
|
+
<label><span>Write mode</span><select name="write">${selectOptions(WRITE_MODES, values.write)}</select></label>
|
|
266
|
+
<label><span>Tool mode</span><select name="toolMode">${selectOptions(TOOL_MODES, values.toolMode)}</select></label>
|
|
267
|
+
<label><span>Codex sessions</span><select name="codexSessions">${selectOptions(CODEX_SESSIONS, values.codexSessions)}</select></label>
|
|
268
|
+
<label><span>Codex directory</span><input name="codexDir" value="${escapeHtml(values.codexDir)}"></label>
|
|
269
|
+
<label><span>Bash session</span><input name="bashSession" value="${escapeHtml(values.bashSession)}"></label>
|
|
270
|
+
</div>
|
|
271
|
+
<label class="check-row"><input name="toolCards" type="checkbox" value="true"${values.toolCards ? " checked" : ""}><span>Enable ChatGPT tool cards</span></label>
|
|
272
|
+
<label class="check-row"><input name="requireBashSession" type="checkbox" value="true"${values.requireBashSession ? " checked" : ""}><span>Require matching bash session id</span></label>
|
|
273
|
+
</fieldset>
|
|
274
|
+
<fieldset class="profile-group readonly-group">
|
|
275
|
+
<legend>Read-only this run</legend>
|
|
276
|
+
<div class="readonly-grid">
|
|
277
|
+
<div><span>Bash transcript</span><code>${escapeHtml(values.bashTranscript)}</code></div>
|
|
278
|
+
<div><span>Widget origin</span><code>${escapeHtml(values.widgetDomain)}</code></div>
|
|
279
|
+
</div>
|
|
280
|
+
</fieldset>
|
|
281
|
+
<div class="actions">
|
|
282
|
+
<button type="submit" class="primary">Save profile</button>
|
|
283
|
+
<span class="mono">${escapeHtml(profilePath)}</span>
|
|
284
|
+
</div>
|
|
285
|
+
<p class="note" data-profile-status>Tokens stay hidden. Restart CodexFlow for saved profile changes to apply.</p>
|
|
286
|
+
</form>
|
|
287
|
+
</section>`;
|
|
288
|
+
}
|
|
289
|
+
function buildProfilePayload(config, existing, input) {
|
|
290
|
+
const current = profileValues(config, existing);
|
|
291
|
+
const next = {
|
|
292
|
+
...current,
|
|
293
|
+
...input,
|
|
294
|
+
port: input.port ? String(input.port) : current.port,
|
|
295
|
+
requireBashSession: input.requireBashSession ?? current.requireBashSession,
|
|
296
|
+
noInstallCloudflared: input.noInstallCloudflared ?? current.noInstallCloudflared
|
|
297
|
+
};
|
|
298
|
+
next.hostname = normalizePublicHostname(next.hostname);
|
|
299
|
+
if (next.tunnel !== "ngrok" && next.tunnel !== "cloudflare-named" && next.tunnel !== "tailscale")
|
|
300
|
+
next.hostname = "";
|
|
301
|
+
next.widgetDomain = normalizeWidgetDomain(next.widgetDomain);
|
|
302
|
+
if ((next.tunnel === "ngrok" || next.tunnel === "cloudflare-named" || next.tunnel === "tailscale") && !next.hostname) {
|
|
303
|
+
throw new Error("hostname is required for ngrok, cloudflare-named, and tailscale profiles.");
|
|
304
|
+
}
|
|
305
|
+
if (next.requireBashSession && !next.bashSession) {
|
|
306
|
+
throw new Error("requireBashSession requires a bashSession value.");
|
|
307
|
+
}
|
|
308
|
+
const token = typeof existing.token === "string" && existing.token ? existing.token : config.authToken ?? "";
|
|
309
|
+
const cloudflareToken = next.tunnel === "cloudflare-named" && typeof existing.cloudflareToken === "string" && existing.cloudflareToken ? existing.cloudflareToken : "";
|
|
310
|
+
const write = effectiveWriteMode(next.mode, next.write);
|
|
311
|
+
const tunnelName = next.tunnel === "cloudflare-named" ? next.tunnelName : "";
|
|
312
|
+
const ngrokConfig = next.tunnel === "ngrok" ? normalizeProfilePath(config.defaultRoot, next.ngrokConfig) : "";
|
|
313
|
+
const cloudflareConfig = next.tunnel === "cloudflare-named" ? normalizeProfilePath(config.defaultRoot, next.cloudflareConfig) : "";
|
|
314
|
+
const cloudflareTokenFile = next.tunnel === "cloudflare-named" ? normalizeProfilePath(config.defaultRoot, next.cloudflareTokenFile) : "";
|
|
315
|
+
return {
|
|
316
|
+
...((existing.allowRoots?.length || config.allowedRoots.length > 1)
|
|
317
|
+
? { allowRoots: existing.allowRoots?.length ? existing.allowRoots : config.allowedRoots.filter((root) => root !== config.defaultRoot) }
|
|
318
|
+
: {}),
|
|
319
|
+
port: next.port,
|
|
320
|
+
mode: next.mode,
|
|
321
|
+
tunnel: next.tunnel,
|
|
322
|
+
...(next.hostname ? { hostname: next.hostname } : {}),
|
|
323
|
+
...(tunnelName ? { tunnelName } : {}),
|
|
324
|
+
...(ngrokConfig ? { ngrokConfig } : {}),
|
|
325
|
+
...(cloudflareConfig ? { cloudflareConfig } : {}),
|
|
326
|
+
...(cloudflareTokenFile ? { cloudflareTokenFile } : {}),
|
|
327
|
+
...(token ? { token } : {}),
|
|
328
|
+
...(cloudflareToken ? { cloudflareToken } : {}),
|
|
329
|
+
bash: next.bash,
|
|
330
|
+
...(next.bashTranscript !== "compact" ? { bashTranscript: next.bashTranscript } : {}),
|
|
331
|
+
...(next.codexSessions !== "off" ? { codexSessions: next.codexSessions } : {}),
|
|
332
|
+
...(next.codexDir ? { codexDir: next.codexDir } : {}),
|
|
333
|
+
...(next.bashSession ? { bashSession: next.bashSession } : {}),
|
|
334
|
+
...(next.requireBashSession ? { requireBashSession: true } : {}),
|
|
335
|
+
write,
|
|
336
|
+
toolMode: next.toolMode,
|
|
337
|
+
toolCards: next.toolCards,
|
|
338
|
+
...(next.widgetDomain ? { widgetDomain: next.widgetDomain } : {}),
|
|
339
|
+
...(next.noInstallCloudflared ? { noInstallCloudflared: true } : {})
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
function profileResponse(config) {
|
|
343
|
+
const profile = readWorkspaceProfile(config.defaultRoot);
|
|
344
|
+
const runtime = readRuntimeConnection(config.defaultRoot);
|
|
345
|
+
return redactStructured({
|
|
346
|
+
ok: true,
|
|
347
|
+
profile_path: profile.profilePath ?? profilePathForRoot(config.defaultRoot),
|
|
348
|
+
exists: Boolean(profile.profilePath),
|
|
349
|
+
profile: sanitizeWorkspaceProfile(profile),
|
|
350
|
+
effective: profileValues(config, profile),
|
|
351
|
+
runtime_connection: runtime,
|
|
352
|
+
runtime: {
|
|
353
|
+
defaultRoot: config.defaultRoot,
|
|
354
|
+
port: config.port,
|
|
355
|
+
bashMode: config.bashMode,
|
|
356
|
+
bashTranscript: config.bashTranscript,
|
|
357
|
+
codexSessions: config.codexSessions,
|
|
358
|
+
writeMode: config.writeMode,
|
|
359
|
+
toolMode: config.toolMode,
|
|
360
|
+
toolCards: config.toolCards,
|
|
361
|
+
widgetDomain: config.widgetDomain,
|
|
362
|
+
authEnabled: Boolean(config.authToken)
|
|
363
|
+
}
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
function jsonError(res, status, code, message, issues) {
|
|
367
|
+
res.status(status).json({
|
|
368
|
+
ok: false,
|
|
369
|
+
error: { code, message, ...(issues ? { issues } : {}) }
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
const LOCAL_FAVICON = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
|
373
|
+
<rect width="64" height="64" rx="16" fill="#2563eb"/>
|
|
374
|
+
<rect x="8" y="8" width="48" height="48" rx="12" fill="#ffffff" fill-opacity=".12" stroke="#ffffff" stroke-opacity=".38"/>
|
|
375
|
+
<path d="M38.4 40.3c-1.8 1.1-3.9 1.7-6.3 1.7-6.1 0-10.3-4.2-10.3-10s4.2-10 10.4-10c2.4 0 4.5.6 6.2 1.7l-2.1 4.1c-1.1-.7-2.3-1-3.8-1-2.9 0-4.9 2.1-4.9 5.2s2 5.2 4.9 5.2c1.5 0 2.8-.4 3.9-1.1l2 4.2Z" fill="#ffffff"/>
|
|
376
|
+
</svg>`;
|
|
377
|
+
const CODEXFLOW_VERSION = "0.29.0-beta.1";
|
|
378
|
+
function printHelp() {
|
|
379
|
+
console.log(`CodexFlow MCP HTTP server
|
|
380
|
+
|
|
381
|
+
Usage:
|
|
382
|
+
codexflow-mcp-http --root /path/to/repo --port 8787
|
|
383
|
+
codexflow-mcp-http --version
|
|
384
|
+
codexflow-mcp-http --help
|
|
385
|
+
|
|
386
|
+
Set CODEXFLOW_HTTP_TOKEN for public/tunnel use.
|
|
387
|
+
For trusted local-only testing, set CODEXFLOW_ALLOW_NO_HTTP_TOKEN=1.
|
|
388
|
+
Most users should run: codexflow`);
|
|
389
|
+
}
|
|
390
|
+
function onboardingPage(config) {
|
|
391
|
+
const localMcp = `http://${config.host}:${config.port}/mcp`;
|
|
392
|
+
const localMcpDisplay = config.authToken ? `${localMcp}?codexflow_token=<redacted>` : localMcp;
|
|
393
|
+
const allowedRoots = config.allowedRoots.map((root) => `<li>${escapeHtml(root)}</li>`).join("");
|
|
394
|
+
const authLabel = config.authToken ? "Token protected" : "Disabled";
|
|
395
|
+
const writeTone = config.writeMode === "workspace" ? "agent" : config.writeMode;
|
|
396
|
+
const rootArg = shellQuote(config.defaultRoot);
|
|
397
|
+
const sessionArg = shellQuote(config.bashSessionId || "main");
|
|
398
|
+
const githubUrl = "https://github.com/tarunspandit/codexflow";
|
|
399
|
+
const npmUrl = "https://www.npmjs.com/package/@tarunspandit/codexflow";
|
|
400
|
+
const docsUrl = "https://tarunspandit.github.io/codexflow/";
|
|
401
|
+
const chatgptUrl = "https://chatgpt.com/#settings/Connectors";
|
|
402
|
+
const controls = [
|
|
403
|
+
copyCommand("Restart CodexFlow", "The bare command rediscovers Codex projects and starts the broker and tunnel automatically.", "codexflow"),
|
|
404
|
+
copyCommand("Copy local MCP URL", "Useful for a local MCP client. ChatGPT usually needs the public tunnel URL copied by the terminal.", localMcp, localMcpDisplay, "local-mcp"),
|
|
405
|
+
copyCommand("Start without bash", "Restart with file tools but no ChatGPT-triggered bash tool.", `codexflow --root ${rootArg} --no-bash`),
|
|
406
|
+
copyCommand("Require explicit bash target", "Restart so bash calls must include this matching session_id.", `codexflow --root ${rootArg} --bash-session ${sessionArg} --require-bash-session`),
|
|
407
|
+
copyCommand("Show Codex session list", "Restart with read-only local Codex session metadata in full tool mode.", `codexflow --root ${rootArg} --tool-mode full --codex-sessions metadata`),
|
|
408
|
+
copyCommand("Read Codex transcripts", "Restart with bounded local transcript reads from Codex JSONL history.", `codexflow --root ${rootArg} --tool-mode full --codex-sessions read`),
|
|
409
|
+
copyCommand("Use full bash transcript", "Restart with the raw stdout/stderr transcript instead of compact tool cards.", `codexflow --root ${rootArg} --bash-transcript full`)
|
|
410
|
+
].join("");
|
|
411
|
+
return `<!doctype html>
|
|
412
|
+
<html lang="en">
|
|
413
|
+
<head>
|
|
414
|
+
<meta charset="utf-8">
|
|
415
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
416
|
+
<link rel="icon" href="/favicon.ico">
|
|
417
|
+
<title>CodexFlow Local Control - ChatGPT Workspace Agent</title>
|
|
418
|
+
<style>
|
|
419
|
+
/* Hallmark · pre-emit critique: P5 H5 E5 S5 R5 V5 */
|
|
420
|
+
/* Hallmark · macrostructure: Workbench · genre: modern-minimal · theme: CC Switch-inspired light manager · tone: technical admin · nav: section switcher · footer: Ft2 · contrast: pass (40-41) · mobile: pass (34, 49, 50-57) */
|
|
421
|
+
:root {
|
|
422
|
+
color-scheme: light;
|
|
423
|
+
--font-display: "Geist", "Aptos", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
424
|
+
--font-body: "Geist", "Aptos", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
425
|
+
--font-mono: "Fira Code", "Geist Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
|
426
|
+
--color-paper: oklch(98.5% 0.004 250);
|
|
427
|
+
--color-surface: oklch(100% 0 0);
|
|
428
|
+
--color-panel: oklch(100% 0 0);
|
|
429
|
+
--color-panel-2: oklch(96.5% 0.008 252);
|
|
430
|
+
--color-row: oklch(99.2% 0.004 250);
|
|
431
|
+
--color-rule: oklch(88% 0.012 250);
|
|
432
|
+
--color-rule-strong: oklch(78% 0.018 250);
|
|
433
|
+
--color-ink: oklch(23% 0.026 255);
|
|
434
|
+
--color-soft: oklch(39% 0.026 255);
|
|
435
|
+
--color-muted: oklch(53% 0.022 255);
|
|
436
|
+
--color-subtle: oklch(64% 0.018 255);
|
|
437
|
+
--color-accent: oklch(58% 0.19 256);
|
|
438
|
+
--color-accent-strong: oklch(50% 0.22 256);
|
|
439
|
+
--color-accent-ink: oklch(99% 0.004 250);
|
|
440
|
+
--color-action: var(--color-accent);
|
|
441
|
+
--color-action-strong: var(--color-accent-strong);
|
|
442
|
+
--color-good: oklch(56% 0.14 154);
|
|
443
|
+
--color-warn: oklch(54% 0.17 256);
|
|
444
|
+
--color-focus: oklch(61% 0.2 256);
|
|
445
|
+
--surface-accent: oklch(95% 0.036 256);
|
|
446
|
+
--surface-good: oklch(94.5% 0.035 154);
|
|
447
|
+
--surface-warn: oklch(95% 0.036 256);
|
|
448
|
+
--surface-hover: oklch(93.5% 0.025 256);
|
|
449
|
+
--shadow-panel: 0 18px 50px oklch(24% 0.03 255 / 0.10);
|
|
450
|
+
--shadow-row: 0 8px 18px oklch(24% 0.03 255 / 0.06);
|
|
451
|
+
--space-1: 0.25rem;
|
|
452
|
+
--space-2: 0.5rem;
|
|
453
|
+
--space-3: 0.75rem;
|
|
454
|
+
--space-4: 1rem;
|
|
455
|
+
--space-5: 1.25rem;
|
|
456
|
+
--space-6: 1.5rem;
|
|
457
|
+
--space-7: 2rem;
|
|
458
|
+
--space-8: 2.5rem;
|
|
459
|
+
--space-9: 3rem;
|
|
460
|
+
--radius-1: 6px;
|
|
461
|
+
--radius-2: 8px;
|
|
462
|
+
--dur-micro: 120ms;
|
|
463
|
+
--dur-short: 180ms;
|
|
464
|
+
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
|
|
465
|
+
--ease-in: cubic-bezier(0.7, 0, 0.84, 0);
|
|
466
|
+
}
|
|
467
|
+
* { box-sizing: border-box; }
|
|
468
|
+
html,
|
|
469
|
+
body {
|
|
470
|
+
overflow-x: clip;
|
|
471
|
+
}
|
|
472
|
+
body {
|
|
473
|
+
margin: 0;
|
|
474
|
+
min-height: 100vh;
|
|
475
|
+
background: var(--color-paper);
|
|
476
|
+
color: var(--color-ink);
|
|
477
|
+
font: 14px/1.55 var(--font-body);
|
|
478
|
+
letter-spacing: 0;
|
|
479
|
+
}
|
|
480
|
+
a {
|
|
481
|
+
color: inherit;
|
|
482
|
+
text-decoration: none;
|
|
483
|
+
}
|
|
484
|
+
button,
|
|
485
|
+
input,
|
|
486
|
+
select {
|
|
487
|
+
font: inherit;
|
|
488
|
+
}
|
|
489
|
+
main {
|
|
490
|
+
width: min(1240px, calc(100% - (var(--space-4) * 2)));
|
|
491
|
+
margin: 0 auto;
|
|
492
|
+
padding: var(--space-5) 0 var(--space-8);
|
|
493
|
+
}
|
|
494
|
+
.topbar {
|
|
495
|
+
display: flex;
|
|
496
|
+
align-items: center;
|
|
497
|
+
justify-content: space-between;
|
|
498
|
+
gap: var(--space-4);
|
|
499
|
+
margin-bottom: var(--space-3);
|
|
500
|
+
padding: var(--space-2) 0 var(--space-3);
|
|
501
|
+
}
|
|
502
|
+
.brand {
|
|
503
|
+
display: flex;
|
|
504
|
+
align-items: center;
|
|
505
|
+
gap: var(--space-3);
|
|
506
|
+
min-width: 0;
|
|
507
|
+
}
|
|
508
|
+
.logo {
|
|
509
|
+
display: inline-grid;
|
|
510
|
+
place-items: center;
|
|
511
|
+
width: 42px;
|
|
512
|
+
height: 42px;
|
|
513
|
+
flex: 0 0 auto;
|
|
514
|
+
border: 1px solid var(--color-accent);
|
|
515
|
+
border-radius: 12px;
|
|
516
|
+
background: var(--color-accent);
|
|
517
|
+
color: var(--color-accent-ink);
|
|
518
|
+
box-shadow: var(--shadow-row);
|
|
519
|
+
font: 900 15px/1 var(--font-mono);
|
|
520
|
+
}
|
|
521
|
+
.logo img {
|
|
522
|
+
display: block;
|
|
523
|
+
width: 100%;
|
|
524
|
+
height: 100%;
|
|
525
|
+
border-radius: inherit;
|
|
526
|
+
}
|
|
527
|
+
.brand-kicker {
|
|
528
|
+
display: block;
|
|
529
|
+
color: var(--color-muted);
|
|
530
|
+
font-size: 12px;
|
|
531
|
+
font-weight: 800;
|
|
532
|
+
line-height: 1.1;
|
|
533
|
+
text-transform: uppercase;
|
|
534
|
+
}
|
|
535
|
+
.brand-title {
|
|
536
|
+
display: block;
|
|
537
|
+
overflow-wrap: anywhere;
|
|
538
|
+
color: var(--color-accent);
|
|
539
|
+
font: 900 28px/1 var(--font-display);
|
|
540
|
+
}
|
|
541
|
+
.quick-links {
|
|
542
|
+
display: flex;
|
|
543
|
+
align-items: center;
|
|
544
|
+
justify-content: flex-end;
|
|
545
|
+
gap: var(--space-2);
|
|
546
|
+
flex-wrap: wrap;
|
|
547
|
+
}
|
|
548
|
+
.resource-link,
|
|
549
|
+
.action-link,
|
|
550
|
+
.copy-mini,
|
|
551
|
+
.primary {
|
|
552
|
+
min-height: 44px;
|
|
553
|
+
display: inline-flex;
|
|
554
|
+
align-items: center;
|
|
555
|
+
justify-content: center;
|
|
556
|
+
border-radius: var(--radius-2);
|
|
557
|
+
white-space: nowrap;
|
|
558
|
+
transition: background-color var(--dur-short) var(--ease-out),
|
|
559
|
+
border-color var(--dur-short) var(--ease-out),
|
|
560
|
+
color var(--dur-short) var(--ease-out),
|
|
561
|
+
transform var(--dur-micro) var(--ease-out);
|
|
562
|
+
}
|
|
563
|
+
.resource-link,
|
|
564
|
+
.action-link {
|
|
565
|
+
border: 1px solid var(--color-rule);
|
|
566
|
+
background: var(--color-surface);
|
|
567
|
+
color: var(--color-soft);
|
|
568
|
+
font-size: 12px;
|
|
569
|
+
font-weight: 800;
|
|
570
|
+
padding: 0 var(--space-3);
|
|
571
|
+
box-shadow: var(--shadow-row);
|
|
572
|
+
}
|
|
573
|
+
.action-link.primary-link {
|
|
574
|
+
border-color: var(--color-action);
|
|
575
|
+
background: var(--color-action);
|
|
576
|
+
color: var(--color-accent-ink);
|
|
577
|
+
}
|
|
578
|
+
.section-tabs {
|
|
579
|
+
display: flex;
|
|
580
|
+
align-items: center;
|
|
581
|
+
justify-content: center;
|
|
582
|
+
gap: var(--space-1);
|
|
583
|
+
flex-wrap: wrap;
|
|
584
|
+
margin: 0 0 var(--space-5);
|
|
585
|
+
padding: var(--space-1);
|
|
586
|
+
border: 1px solid var(--color-rule);
|
|
587
|
+
border-radius: 16px;
|
|
588
|
+
background: var(--color-surface);
|
|
589
|
+
box-shadow: var(--shadow-panel);
|
|
590
|
+
}
|
|
591
|
+
.section-tabs a {
|
|
592
|
+
min-height: 40px;
|
|
593
|
+
display: inline-flex;
|
|
594
|
+
align-items: center;
|
|
595
|
+
justify-content: center;
|
|
596
|
+
border-radius: 12px;
|
|
597
|
+
color: var(--color-muted);
|
|
598
|
+
font-size: 13px;
|
|
599
|
+
font-weight: 900;
|
|
600
|
+
white-space: nowrap;
|
|
601
|
+
padding: 0 var(--space-4);
|
|
602
|
+
}
|
|
603
|
+
.section-tabs a[aria-current="page"] {
|
|
604
|
+
background: var(--color-accent);
|
|
605
|
+
color: var(--color-accent-ink);
|
|
606
|
+
box-shadow: var(--shadow-row);
|
|
607
|
+
}
|
|
608
|
+
.overview {
|
|
609
|
+
display: grid;
|
|
610
|
+
grid-template-columns: minmax(0, 1.08fr) minmax(330px, 0.52fr);
|
|
611
|
+
gap: var(--space-5);
|
|
612
|
+
align-items: start;
|
|
613
|
+
margin-bottom: var(--space-5);
|
|
614
|
+
}
|
|
615
|
+
.intro-stack {
|
|
616
|
+
display: grid;
|
|
617
|
+
gap: var(--space-5);
|
|
618
|
+
min-width: 0;
|
|
619
|
+
}
|
|
620
|
+
.intro {
|
|
621
|
+
min-width: 0;
|
|
622
|
+
padding: var(--space-6);
|
|
623
|
+
border: 1px solid var(--color-rule);
|
|
624
|
+
border-radius: 18px;
|
|
625
|
+
background: var(--color-surface);
|
|
626
|
+
box-shadow: var(--shadow-panel);
|
|
627
|
+
}
|
|
628
|
+
h1 {
|
|
629
|
+
margin: 0;
|
|
630
|
+
max-width: 18ch;
|
|
631
|
+
font: 900 2rem/1.05 var(--font-display);
|
|
632
|
+
letter-spacing: 0;
|
|
633
|
+
overflow-wrap: anywhere;
|
|
634
|
+
}
|
|
635
|
+
.lead {
|
|
636
|
+
max-width: 64ch;
|
|
637
|
+
margin: var(--space-4) 0 0;
|
|
638
|
+
color: var(--color-soft);
|
|
639
|
+
font-size: 15px;
|
|
640
|
+
}
|
|
641
|
+
.scope-note {
|
|
642
|
+
margin-top: var(--space-5);
|
|
643
|
+
padding-top: var(--space-4);
|
|
644
|
+
border-top: 1px solid var(--color-rule);
|
|
645
|
+
color: var(--color-muted);
|
|
646
|
+
font-size: 13px;
|
|
647
|
+
}
|
|
648
|
+
.run-card,
|
|
649
|
+
.panel {
|
|
650
|
+
min-width: 0;
|
|
651
|
+
border: 1px solid var(--color-rule);
|
|
652
|
+
border-radius: 18px;
|
|
653
|
+
background: var(--color-panel);
|
|
654
|
+
box-shadow: var(--shadow-panel);
|
|
655
|
+
}
|
|
656
|
+
.run-card,
|
|
657
|
+
.panel {
|
|
658
|
+
padding: var(--space-5);
|
|
659
|
+
}
|
|
660
|
+
.run-card h2,
|
|
661
|
+
.panel h2 {
|
|
662
|
+
margin: 0;
|
|
663
|
+
color: var(--color-ink);
|
|
664
|
+
font: 700 16px/1.25 var(--font-display);
|
|
665
|
+
letter-spacing: 0;
|
|
666
|
+
}
|
|
667
|
+
.workspace-grid {
|
|
668
|
+
display: grid;
|
|
669
|
+
grid-template-columns: minmax(0, 1.08fr) minmax(310px, 0.58fr);
|
|
670
|
+
gap: var(--space-5);
|
|
671
|
+
align-items: start;
|
|
672
|
+
}
|
|
673
|
+
.side-stack {
|
|
674
|
+
display: grid;
|
|
675
|
+
gap: var(--space-5);
|
|
676
|
+
}
|
|
677
|
+
.section-head {
|
|
678
|
+
display: flex;
|
|
679
|
+
align-items: start;
|
|
680
|
+
justify-content: space-between;
|
|
681
|
+
gap: var(--space-4);
|
|
682
|
+
margin-bottom: var(--space-4);
|
|
683
|
+
}
|
|
684
|
+
.section-head p,
|
|
685
|
+
.panel > p {
|
|
686
|
+
margin: var(--space-1) 0 0;
|
|
687
|
+
color: var(--color-muted);
|
|
688
|
+
font-size: 13px;
|
|
689
|
+
}
|
|
690
|
+
.status {
|
|
691
|
+
display: grid;
|
|
692
|
+
}
|
|
693
|
+
.row {
|
|
694
|
+
display: grid;
|
|
695
|
+
grid-template-columns: 132px minmax(0, 1fr);
|
|
696
|
+
gap: var(--space-3);
|
|
697
|
+
padding: var(--space-3) 0;
|
|
698
|
+
border-bottom: 1px solid var(--color-rule);
|
|
699
|
+
}
|
|
700
|
+
.row:last-child { border-bottom: 0; }
|
|
701
|
+
.label {
|
|
702
|
+
color: var(--color-subtle);
|
|
703
|
+
font-size: 11px;
|
|
704
|
+
font-weight: 800;
|
|
705
|
+
text-transform: uppercase;
|
|
706
|
+
}
|
|
707
|
+
code,
|
|
708
|
+
.mono {
|
|
709
|
+
font-family: var(--font-mono);
|
|
710
|
+
color: var(--color-soft);
|
|
711
|
+
overflow-wrap: anywhere;
|
|
712
|
+
font-variant-numeric: tabular-nums;
|
|
713
|
+
}
|
|
714
|
+
.pill {
|
|
715
|
+
display: inline-flex;
|
|
716
|
+
width: fit-content;
|
|
717
|
+
align-items: center;
|
|
718
|
+
min-height: 26px;
|
|
719
|
+
padding: 0 var(--space-2);
|
|
720
|
+
border: 1px solid var(--color-good);
|
|
721
|
+
border-radius: 999px;
|
|
722
|
+
background: var(--surface-good);
|
|
723
|
+
color: var(--color-good);
|
|
724
|
+
font-size: 12px;
|
|
725
|
+
font-weight: 800;
|
|
726
|
+
line-height: 1;
|
|
727
|
+
white-space: nowrap;
|
|
728
|
+
}
|
|
729
|
+
.warn {
|
|
730
|
+
border-color: var(--color-warn);
|
|
731
|
+
background: var(--surface-warn);
|
|
732
|
+
color: var(--color-warn);
|
|
733
|
+
}
|
|
734
|
+
.controls {
|
|
735
|
+
display: grid;
|
|
736
|
+
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
737
|
+
gap: var(--space-3);
|
|
738
|
+
}
|
|
739
|
+
.control {
|
|
740
|
+
display: grid;
|
|
741
|
+
grid-template-columns: minmax(0, 1fr) auto;
|
|
742
|
+
gap: var(--space-3);
|
|
743
|
+
align-items: center;
|
|
744
|
+
min-width: 0;
|
|
745
|
+
min-height: 112px;
|
|
746
|
+
padding: var(--space-4);
|
|
747
|
+
border: 1px solid var(--color-rule);
|
|
748
|
+
border-radius: 18px;
|
|
749
|
+
background: var(--color-row);
|
|
750
|
+
box-shadow: var(--shadow-row);
|
|
751
|
+
}
|
|
752
|
+
.control strong {
|
|
753
|
+
display: block;
|
|
754
|
+
margin-bottom: var(--space-1);
|
|
755
|
+
color: var(--color-ink);
|
|
756
|
+
font-size: 13px;
|
|
757
|
+
}
|
|
758
|
+
.control p {
|
|
759
|
+
margin: 0 0 var(--space-2);
|
|
760
|
+
color: var(--color-muted);
|
|
761
|
+
font-size: 12px;
|
|
762
|
+
}
|
|
763
|
+
.control code {
|
|
764
|
+
display: block;
|
|
765
|
+
padding: var(--space-2);
|
|
766
|
+
border: 1px solid var(--color-rule);
|
|
767
|
+
border-radius: 10px;
|
|
768
|
+
background: var(--color-panel-2);
|
|
769
|
+
line-height: 1.45;
|
|
770
|
+
}
|
|
771
|
+
.steps {
|
|
772
|
+
display: grid;
|
|
773
|
+
gap: var(--space-3);
|
|
774
|
+
margin: 0;
|
|
775
|
+
padding: 0;
|
|
776
|
+
list-style: none;
|
|
777
|
+
}
|
|
778
|
+
.guide-list {
|
|
779
|
+
display: grid;
|
|
780
|
+
gap: var(--space-3);
|
|
781
|
+
}
|
|
782
|
+
.guide-item {
|
|
783
|
+
display: grid;
|
|
784
|
+
grid-template-columns: 28px minmax(0, 1fr);
|
|
785
|
+
gap: var(--space-3);
|
|
786
|
+
padding: var(--space-3);
|
|
787
|
+
border: 1px solid var(--color-rule);
|
|
788
|
+
border-radius: 14px;
|
|
789
|
+
background: var(--color-row);
|
|
790
|
+
}
|
|
791
|
+
.guide-item strong {
|
|
792
|
+
display: block;
|
|
793
|
+
color: var(--color-ink);
|
|
794
|
+
font-size: 13px;
|
|
795
|
+
}
|
|
796
|
+
.guide-item p {
|
|
797
|
+
margin: var(--space-1) 0 0;
|
|
798
|
+
color: var(--color-muted);
|
|
799
|
+
font-size: 12px;
|
|
800
|
+
}
|
|
801
|
+
.steps li {
|
|
802
|
+
display: grid;
|
|
803
|
+
grid-template-columns: 28px minmax(0, 1fr);
|
|
804
|
+
gap: var(--space-3);
|
|
805
|
+
align-items: start;
|
|
806
|
+
color: var(--color-soft);
|
|
807
|
+
}
|
|
808
|
+
.num {
|
|
809
|
+
display: inline-grid;
|
|
810
|
+
place-items: center;
|
|
811
|
+
width: 26px;
|
|
812
|
+
height: 26px;
|
|
813
|
+
border: 1px solid var(--color-accent);
|
|
814
|
+
border-radius: 999px;
|
|
815
|
+
background: var(--color-accent);
|
|
816
|
+
color: var(--color-accent-ink);
|
|
817
|
+
font: 800 11px/1 var(--font-mono);
|
|
818
|
+
}
|
|
819
|
+
.roots {
|
|
820
|
+
margin: var(--space-3) 0 0;
|
|
821
|
+
padding-left: var(--space-5);
|
|
822
|
+
color: var(--color-muted);
|
|
823
|
+
}
|
|
824
|
+
.profile-form {
|
|
825
|
+
display: grid;
|
|
826
|
+
gap: var(--space-4);
|
|
827
|
+
}
|
|
828
|
+
.current-url {
|
|
829
|
+
display: grid;
|
|
830
|
+
grid-template-columns: minmax(0, 1fr) auto;
|
|
831
|
+
gap: var(--space-3);
|
|
832
|
+
align-items: center;
|
|
833
|
+
min-width: 0;
|
|
834
|
+
padding: var(--space-4);
|
|
835
|
+
border: 1px solid var(--color-accent);
|
|
836
|
+
border-radius: 18px;
|
|
837
|
+
background: var(--surface-accent);
|
|
838
|
+
}
|
|
839
|
+
.current-url.idle {
|
|
840
|
+
border-color: var(--color-rule);
|
|
841
|
+
background: var(--color-row);
|
|
842
|
+
}
|
|
843
|
+
.current-url span,
|
|
844
|
+
.readonly-grid span {
|
|
845
|
+
display: block;
|
|
846
|
+
margin-bottom: var(--space-1);
|
|
847
|
+
color: var(--color-muted);
|
|
848
|
+
font-size: 11px;
|
|
849
|
+
font-weight: 900;
|
|
850
|
+
text-transform: uppercase;
|
|
851
|
+
}
|
|
852
|
+
.current-url code {
|
|
853
|
+
display: block;
|
|
854
|
+
color: var(--color-ink);
|
|
855
|
+
font-size: 12px;
|
|
856
|
+
line-height: 1.45;
|
|
857
|
+
}
|
|
858
|
+
.current-url p {
|
|
859
|
+
margin: var(--space-2) 0 0;
|
|
860
|
+
color: var(--color-soft);
|
|
861
|
+
font-size: 12px;
|
|
862
|
+
}
|
|
863
|
+
.profile-group {
|
|
864
|
+
min-width: 0;
|
|
865
|
+
margin: 0;
|
|
866
|
+
padding: var(--space-4);
|
|
867
|
+
border: 1px solid var(--color-rule);
|
|
868
|
+
border-radius: 18px;
|
|
869
|
+
background: var(--color-row);
|
|
870
|
+
box-shadow: var(--shadow-row);
|
|
871
|
+
}
|
|
872
|
+
.profile-group legend {
|
|
873
|
+
padding: 0 var(--space-2);
|
|
874
|
+
color: var(--color-ink);
|
|
875
|
+
font: 900 13px/1 var(--font-display);
|
|
876
|
+
}
|
|
877
|
+
.profile-group p {
|
|
878
|
+
margin: 0 0 var(--space-3);
|
|
879
|
+
color: var(--color-muted);
|
|
880
|
+
font-size: 12px;
|
|
881
|
+
}
|
|
882
|
+
.form-grid {
|
|
883
|
+
display: grid;
|
|
884
|
+
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
885
|
+
gap: var(--space-3);
|
|
886
|
+
}
|
|
887
|
+
.profile-form label {
|
|
888
|
+
display: grid;
|
|
889
|
+
gap: var(--space-2);
|
|
890
|
+
color: var(--color-soft);
|
|
891
|
+
font-size: 12px;
|
|
892
|
+
font-weight: 800;
|
|
893
|
+
}
|
|
894
|
+
.profile-form label span {
|
|
895
|
+
color: var(--color-muted);
|
|
896
|
+
text-transform: uppercase;
|
|
897
|
+
}
|
|
898
|
+
.profile-form input,
|
|
899
|
+
.profile-form select {
|
|
900
|
+
width: 100%;
|
|
901
|
+
min-height: 44px;
|
|
902
|
+
border: 1px solid var(--color-rule);
|
|
903
|
+
border-radius: 12px;
|
|
904
|
+
outline: 2px solid transparent;
|
|
905
|
+
outline-offset: 1px;
|
|
906
|
+
background: var(--color-surface);
|
|
907
|
+
color: var(--color-ink);
|
|
908
|
+
font: 13px/1.25 var(--font-body);
|
|
909
|
+
padding: 0 var(--space-3);
|
|
910
|
+
}
|
|
911
|
+
.field-help {
|
|
912
|
+
min-height: 1lh;
|
|
913
|
+
margin: var(--space-3) 0 0 !important;
|
|
914
|
+
color: var(--color-soft) !important;
|
|
915
|
+
font-size: 12px !important;
|
|
916
|
+
}
|
|
917
|
+
.check-row {
|
|
918
|
+
width: fit-content;
|
|
919
|
+
display: inline-flex !important;
|
|
920
|
+
grid-template-columns: none !important;
|
|
921
|
+
align-items: center;
|
|
922
|
+
gap: var(--space-2) !important;
|
|
923
|
+
margin-top: var(--space-3);
|
|
924
|
+
padding: var(--space-2) var(--space-3);
|
|
925
|
+
border: 1px solid var(--color-rule);
|
|
926
|
+
border-radius: 999px;
|
|
927
|
+
background: var(--color-surface);
|
|
928
|
+
color: var(--color-soft);
|
|
929
|
+
}
|
|
930
|
+
.check-row input {
|
|
931
|
+
width: 18px;
|
|
932
|
+
min-height: 18px;
|
|
933
|
+
height: 18px;
|
|
934
|
+
padding: 0;
|
|
935
|
+
accent-color: var(--color-accent);
|
|
936
|
+
}
|
|
937
|
+
.check-row span {
|
|
938
|
+
color: var(--color-soft) !important;
|
|
939
|
+
text-transform: none !important;
|
|
940
|
+
}
|
|
941
|
+
.readonly-grid {
|
|
942
|
+
display: grid;
|
|
943
|
+
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
944
|
+
gap: var(--space-3);
|
|
945
|
+
}
|
|
946
|
+
.readonly-grid > div {
|
|
947
|
+
min-width: 0;
|
|
948
|
+
padding: var(--space-3);
|
|
949
|
+
border: 1px solid var(--color-rule);
|
|
950
|
+
border-radius: 14px;
|
|
951
|
+
background: var(--color-surface);
|
|
952
|
+
}
|
|
953
|
+
.readonly-grid code {
|
|
954
|
+
display: block;
|
|
955
|
+
font-size: 12px;
|
|
956
|
+
line-height: 1.45;
|
|
957
|
+
}
|
|
958
|
+
.actions {
|
|
959
|
+
display: flex;
|
|
960
|
+
align-items: center;
|
|
961
|
+
gap: var(--space-3);
|
|
962
|
+
min-width: 0;
|
|
963
|
+
flex-wrap: wrap;
|
|
964
|
+
}
|
|
965
|
+
.primary {
|
|
966
|
+
border: 1px solid var(--color-accent);
|
|
967
|
+
background: var(--color-accent);
|
|
968
|
+
color: var(--color-accent-ink);
|
|
969
|
+
cursor: pointer;
|
|
970
|
+
font: 800 13px/1 var(--font-body);
|
|
971
|
+
padding: 0 var(--space-4);
|
|
972
|
+
box-shadow: var(--shadow-row);
|
|
973
|
+
}
|
|
974
|
+
.copy-mini {
|
|
975
|
+
border: 1px solid var(--color-action);
|
|
976
|
+
background: var(--color-action);
|
|
977
|
+
color: var(--color-accent-ink);
|
|
978
|
+
cursor: pointer;
|
|
979
|
+
font: 800 12px/1 var(--font-body);
|
|
980
|
+
padding: 0 var(--space-3);
|
|
981
|
+
}
|
|
982
|
+
.note {
|
|
983
|
+
min-height: 1lh;
|
|
984
|
+
margin: var(--space-1) 0 0;
|
|
985
|
+
color: var(--color-subtle);
|
|
986
|
+
font-size: 12px;
|
|
987
|
+
}
|
|
988
|
+
.scope-list {
|
|
989
|
+
display: grid;
|
|
990
|
+
gap: var(--space-2);
|
|
991
|
+
margin: var(--space-3) 0 0;
|
|
992
|
+
padding: 0;
|
|
993
|
+
list-style: none;
|
|
994
|
+
}
|
|
995
|
+
.scope-list li {
|
|
996
|
+
display: grid;
|
|
997
|
+
grid-template-columns: 112px minmax(0, 1fr);
|
|
998
|
+
gap: var(--space-3);
|
|
999
|
+
padding-block: var(--space-2);
|
|
1000
|
+
border-bottom: 1px solid var(--color-rule);
|
|
1001
|
+
color: var(--color-muted);
|
|
1002
|
+
}
|
|
1003
|
+
.scope-list li:last-child {
|
|
1004
|
+
border-bottom: 0;
|
|
1005
|
+
}
|
|
1006
|
+
.scope-list strong {
|
|
1007
|
+
color: var(--color-soft);
|
|
1008
|
+
font-family: var(--font-mono);
|
|
1009
|
+
font-size: 12px;
|
|
1010
|
+
}
|
|
1011
|
+
.details-panel {
|
|
1012
|
+
margin-top: var(--space-5);
|
|
1013
|
+
}
|
|
1014
|
+
details summary {
|
|
1015
|
+
min-height: 44px;
|
|
1016
|
+
display: flex;
|
|
1017
|
+
align-items: center;
|
|
1018
|
+
justify-content: space-between;
|
|
1019
|
+
gap: var(--space-3);
|
|
1020
|
+
cursor: pointer;
|
|
1021
|
+
color: var(--color-ink);
|
|
1022
|
+
font: 800 14px/1 var(--font-body);
|
|
1023
|
+
list-style: none;
|
|
1024
|
+
}
|
|
1025
|
+
details summary::-webkit-details-marker {
|
|
1026
|
+
display: none;
|
|
1027
|
+
}
|
|
1028
|
+
details summary::after {
|
|
1029
|
+
content: "+";
|
|
1030
|
+
color: var(--color-accent);
|
|
1031
|
+
font: 800 18px/1 var(--font-mono);
|
|
1032
|
+
}
|
|
1033
|
+
details[open] summary {
|
|
1034
|
+
margin-bottom: var(--space-4);
|
|
1035
|
+
}
|
|
1036
|
+
details[open] summary::after {
|
|
1037
|
+
content: "-";
|
|
1038
|
+
}
|
|
1039
|
+
.foot {
|
|
1040
|
+
margin-top: var(--space-6);
|
|
1041
|
+
padding-top: var(--space-4);
|
|
1042
|
+
border-top: 1px solid var(--color-rule);
|
|
1043
|
+
color: var(--color-subtle);
|
|
1044
|
+
font-size: 12px;
|
|
1045
|
+
}
|
|
1046
|
+
:focus {
|
|
1047
|
+
outline: none;
|
|
1048
|
+
}
|
|
1049
|
+
:focus-visible {
|
|
1050
|
+
outline: 2px solid var(--color-focus);
|
|
1051
|
+
outline-offset: 2px;
|
|
1052
|
+
}
|
|
1053
|
+
.profile-form input:focus-visible,
|
|
1054
|
+
.profile-form select:focus-visible {
|
|
1055
|
+
outline: 2px solid var(--color-focus);
|
|
1056
|
+
outline-offset: 1px;
|
|
1057
|
+
}
|
|
1058
|
+
.resource-link:active,
|
|
1059
|
+
.action-link:active,
|
|
1060
|
+
.copy-mini:active,
|
|
1061
|
+
.primary:active {
|
|
1062
|
+
transform: translateY(1px);
|
|
1063
|
+
}
|
|
1064
|
+
.resource-link[aria-disabled="true"],
|
|
1065
|
+
.action-link[aria-disabled="true"],
|
|
1066
|
+
.copy-mini:disabled,
|
|
1067
|
+
.primary:disabled {
|
|
1068
|
+
cursor: not-allowed;
|
|
1069
|
+
opacity: 0.55;
|
|
1070
|
+
}
|
|
1071
|
+
@media (hover: hover) and (pointer: fine) {
|
|
1072
|
+
.resource-link:hover,
|
|
1073
|
+
.action-link:hover,
|
|
1074
|
+
.copy-mini:hover,
|
|
1075
|
+
.primary:hover {
|
|
1076
|
+
border-color: var(--color-accent);
|
|
1077
|
+
background: var(--surface-hover);
|
|
1078
|
+
color: var(--color-accent-strong);
|
|
1079
|
+
transform: translateY(-1px);
|
|
1080
|
+
}
|
|
1081
|
+
.action-link.primary-link:hover,
|
|
1082
|
+
.copy-mini:hover,
|
|
1083
|
+
.primary:hover {
|
|
1084
|
+
border-color: var(--color-action-strong);
|
|
1085
|
+
background: var(--color-action-strong);
|
|
1086
|
+
color: var(--color-accent-ink);
|
|
1087
|
+
}
|
|
1088
|
+
.section-tabs a:hover {
|
|
1089
|
+
background: var(--surface-hover);
|
|
1090
|
+
color: var(--color-accent-strong);
|
|
1091
|
+
}
|
|
1092
|
+
.profile-form input:hover,
|
|
1093
|
+
.profile-form select:hover {
|
|
1094
|
+
border-color: var(--color-rule-strong);
|
|
1095
|
+
background: var(--color-panel-2);
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
@media (min-width: 52rem) {
|
|
1099
|
+
h1 {
|
|
1100
|
+
font-size: 2.45rem;
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
@media (max-width: 58rem) {
|
|
1104
|
+
.overview,
|
|
1105
|
+
.workspace-grid {
|
|
1106
|
+
grid-template-columns: 1fr;
|
|
1107
|
+
}
|
|
1108
|
+
.topbar {
|
|
1109
|
+
align-items: flex-start;
|
|
1110
|
+
flex-direction: column;
|
|
1111
|
+
}
|
|
1112
|
+
.quick-links {
|
|
1113
|
+
justify-content: flex-start;
|
|
1114
|
+
}
|
|
1115
|
+
.section-tabs {
|
|
1116
|
+
justify-content: flex-start;
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
@media (max-width: 42rem) {
|
|
1120
|
+
main {
|
|
1121
|
+
width: min(100% - (var(--space-3) * 2), 1180px);
|
|
1122
|
+
padding-block: var(--space-4) var(--space-7);
|
|
1123
|
+
}
|
|
1124
|
+
.intro,
|
|
1125
|
+
.run-card,
|
|
1126
|
+
.panel {
|
|
1127
|
+
padding: var(--space-4);
|
|
1128
|
+
}
|
|
1129
|
+
.section-head {
|
|
1130
|
+
align-items: start;
|
|
1131
|
+
flex-direction: column;
|
|
1132
|
+
}
|
|
1133
|
+
h1 {
|
|
1134
|
+
font-size: 1.85rem;
|
|
1135
|
+
}
|
|
1136
|
+
.row,
|
|
1137
|
+
.scope-list li {
|
|
1138
|
+
grid-template-columns: 1fr;
|
|
1139
|
+
gap: var(--space-1);
|
|
1140
|
+
}
|
|
1141
|
+
.controls,
|
|
1142
|
+
.form-grid {
|
|
1143
|
+
grid-template-columns: 1fr;
|
|
1144
|
+
}
|
|
1145
|
+
.control {
|
|
1146
|
+
grid-template-columns: 1fr;
|
|
1147
|
+
}
|
|
1148
|
+
.current-url,
|
|
1149
|
+
.readonly-grid {
|
|
1150
|
+
grid-template-columns: 1fr;
|
|
1151
|
+
}
|
|
1152
|
+
.actions {
|
|
1153
|
+
align-items: stretch;
|
|
1154
|
+
flex-direction: column;
|
|
1155
|
+
}
|
|
1156
|
+
.primary {
|
|
1157
|
+
width: 100%;
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
@media (prefers-reduced-motion: reduce) {
|
|
1161
|
+
*,
|
|
1162
|
+
*::before,
|
|
1163
|
+
*::after {
|
|
1164
|
+
animation-duration: 150ms !important;
|
|
1165
|
+
animation-iteration-count: 1 !important;
|
|
1166
|
+
transition-duration: 150ms !important;
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
</style>
|
|
1170
|
+
</head>
|
|
1171
|
+
<body>
|
|
1172
|
+
<main>
|
|
1173
|
+
<header class="topbar">
|
|
1174
|
+
<div class="brand">
|
|
1175
|
+
<span class="logo" aria-hidden="true"><img src="/favicon.ico" alt=""></span>
|
|
1176
|
+
<span>
|
|
1177
|
+
<span class="brand-kicker">Workspace control</span>
|
|
1178
|
+
<span class="brand-title">CodexFlow</span>
|
|
1179
|
+
</span>
|
|
1180
|
+
</div>
|
|
1181
|
+
<nav class="quick-links" aria-label="CodexFlow resources">
|
|
1182
|
+
<a class="action-link primary-link" href="${chatgptUrl}" target="_blank" rel="noreferrer">Open ChatGPT settings</a>
|
|
1183
|
+
<a class="resource-link" href="${githubUrl}" target="_blank" rel="noreferrer">Open GitHub</a>
|
|
1184
|
+
<a class="resource-link" href="${npmUrl}" target="_blank" rel="noreferrer">NPM</a>
|
|
1185
|
+
<a class="resource-link" href="${docsUrl}" target="_blank" rel="noreferrer">Docs</a>
|
|
1186
|
+
</nav>
|
|
1187
|
+
</header>
|
|
1188
|
+
<nav class="section-tabs" aria-label="Admin sections">
|
|
1189
|
+
<a href="#profile" aria-current="page">Profile</a>
|
|
1190
|
+
<a href="#status">Status</a>
|
|
1191
|
+
<a href="#connect">ChatGPT</a>
|
|
1192
|
+
<a href="#access">Access</a>
|
|
1193
|
+
<a href="#cli">CLI</a>
|
|
1194
|
+
</nav>
|
|
1195
|
+
<section class="overview">
|
|
1196
|
+
${profileForm(config)}
|
|
1197
|
+
<aside class="side-stack">
|
|
1198
|
+
<section class="panel guide-panel" id="guide">
|
|
1199
|
+
<div class="section-head">
|
|
1200
|
+
<div>
|
|
1201
|
+
<h2>Quick path</h2>
|
|
1202
|
+
<p>Use ChatGPT like a coding agent for this workspace without widening the local trust boundary.</p>
|
|
1203
|
+
</div>
|
|
1204
|
+
</div>
|
|
1205
|
+
<div class="guide-list">
|
|
1206
|
+
<div class="guide-item"><span class="num">1</span><span><strong>Review the profile</strong><p>Choose the tunnel, port, mode, bash, write, tool, Codex session, and workspace defaults for the next launch.</p></span></div>
|
|
1207
|
+
<div class="guide-item"><span class="num">2</span><span><strong>Copy the Server URL</strong><p>Use the current public URL shown in the profile when available, or the one printed by the terminal after launch.</p></span></div>
|
|
1208
|
+
<div class="guide-item"><span class="num">3</span><span><strong>Create the ChatGPT app</strong><p>Choose Server URL, paste the copied URL, and use no extra authentication. The private token is already in the URL.</p></span></div>
|
|
1209
|
+
<div class="guide-item"><span class="num">4</span><span><strong>Restart for policy changes</strong><p>Saved profile changes apply when CodexFlow starts again. The live server does not mutate under an active ChatGPT session.</p></span></div>
|
|
1210
|
+
</div>
|
|
1211
|
+
</section>
|
|
1212
|
+
<article class="run-card" id="status" aria-label="Current runtime">
|
|
1213
|
+
<h2>Runtime guardrails</h2>
|
|
1214
|
+
<div class="status">
|
|
1215
|
+
<div class="row"><span class="label">Workspace</span><span class="mono">${escapeHtml(config.defaultRoot)}</span></div>
|
|
1216
|
+
<div class="row"><span class="label">Local MCP</span><span class="mono">${escapeHtml(localMcp)}</span></div>
|
|
1217
|
+
<div class="row"><span class="label">Write mode</span><span class="pill ${config.writeMode === "workspace" ? "" : "warn"}">${escapeHtml(writeTone)}</span></div>
|
|
1218
|
+
<div class="row"><span class="label">Tool mode</span><span class="pill ${config.toolMode === "standard" ? "" : "warn"}">${escapeHtml(config.toolMode)}</span></div>
|
|
1219
|
+
<div class="row"><span class="label">Bash mode</span><span class="pill ${config.bashMode === "safe" ? "" : "warn"}">${escapeHtml(config.bashMode)}</span></div>
|
|
1220
|
+
<div class="row"><span class="label">Transcript</span><span class="pill ${config.bashTranscript === "compact" ? "" : "warn"}">${escapeHtml(config.bashTranscript)}</span></div>
|
|
1221
|
+
<div class="row"><span class="label">Bash session</span><span class="pill ${config.requireBashSession ? "warn" : ""}">${escapeHtml(config.bashSessionId ? `${config.bashSessionId}${config.requireBashSession ? " required" : ""}` : "not set")}</span></div>
|
|
1222
|
+
<div class="row"><span class="label">Codex sessions</span><span class="pill ${config.codexSessions === "off" ? "" : "warn"}">${escapeHtml(config.codexSessions)}</span></div>
|
|
1223
|
+
<div class="row"><span class="label">Widget domain</span><span class="mono">${escapeHtml(config.widgetDomain)}</span></div>
|
|
1224
|
+
<div class="row"><span class="label">Auth</span><span class="pill">${escapeHtml(authLabel)}</span></div>
|
|
1225
|
+
</div>
|
|
1226
|
+
</article>
|
|
1227
|
+
</aside>
|
|
1228
|
+
</section>
|
|
1229
|
+
<section class="workspace-grid">
|
|
1230
|
+
<section class="panel" id="connect">
|
|
1231
|
+
<div class="section-head">
|
|
1232
|
+
<div>
|
|
1233
|
+
<h2>Connect ChatGPT</h2>
|
|
1234
|
+
<p>Create an app connection that points at the public Server URL copied by the terminal.</p>
|
|
1235
|
+
</div>
|
|
1236
|
+
</div>
|
|
1237
|
+
<ol class="steps">
|
|
1238
|
+
<li><span class="num">1</span><span>Open ChatGPT settings and create an app connection.</span></li>
|
|
1239
|
+
<li><span class="num">2</span><span>Set Connection to <code>Server URL</code>.</span></li>
|
|
1240
|
+
<li><span class="num">3</span><span>Paste the public CodexFlow URL from the terminal.</span></li>
|
|
1241
|
+
<li><span class="num">4</span><span>Use <code>No Authentication / None</code>; the private token is already in the copied URL.</span></li>
|
|
1242
|
+
</ol>
|
|
1243
|
+
<p class="note"><a class="action-link" href="${chatgptUrl}" target="_blank" rel="noreferrer">Open ChatGPT settings</a></p>
|
|
1244
|
+
</section>
|
|
1245
|
+
<aside class="side-stack">
|
|
1246
|
+
<section class="panel" id="access">
|
|
1247
|
+
<h2>Admin boundary</h2>
|
|
1248
|
+
<ul class="scope-list">
|
|
1249
|
+
<li><strong>/setup</strong><span>legacy alias for this local dashboard</span></li>
|
|
1250
|
+
<li><strong>/admin/profile</strong><span>saved workspace profile API</span></li>
|
|
1251
|
+
<li><strong>/healthz</strong><span>authenticated status check</span></li>
|
|
1252
|
+
<li><strong>/mcp</strong><span>MCP endpoint for ChatGPT and local clients</span></li>
|
|
1253
|
+
</ul>
|
|
1254
|
+
</section>
|
|
1255
|
+
</aside>
|
|
1256
|
+
</section>
|
|
1257
|
+
<section class="panel cli-panel details-panel" id="cli">
|
|
1258
|
+
<div class="section-head">
|
|
1259
|
+
<div>
|
|
1260
|
+
<h2>CLI controls</h2>
|
|
1261
|
+
<p>Copy these when you need to restart with a different runtime policy. They do not mutate the running process from the browser.</p>
|
|
1262
|
+
</div>
|
|
1263
|
+
</div>
|
|
1264
|
+
<div class="controls">${controls}</div>
|
|
1265
|
+
</section>
|
|
1266
|
+
<details class="panel details-panel">
|
|
1267
|
+
<summary>Allowed roots</summary>
|
|
1268
|
+
<ul class="roots">${allowedRoots}</ul>
|
|
1269
|
+
<p class="note">CodexFlow rejects workspace access outside these roots.</p>
|
|
1270
|
+
</details>
|
|
1271
|
+
<footer class="foot">Token-protected local control surface for this workspace. Public sharing still happens only through your chosen tunnel.</footer>
|
|
1272
|
+
</main>
|
|
1273
|
+
<script>
|
|
1274
|
+
document.querySelectorAll("[data-copy], [data-copy-kind]").forEach((button) => {
|
|
1275
|
+
button.addEventListener("click", async () => {
|
|
1276
|
+
let value = button.getAttribute("data-copy") || "";
|
|
1277
|
+
if (button.getAttribute("data-copy-kind") === "local-mcp") {
|
|
1278
|
+
const base = button.getAttribute("data-copy-base") || value;
|
|
1279
|
+
const params = new URLSearchParams(window.location.search);
|
|
1280
|
+
const token = params.get("codexflow_token") || params.get("token") || "";
|
|
1281
|
+
value = token ? base + "?codexflow_token=" + encodeURIComponent(token) : base;
|
|
1282
|
+
} else if (button.getAttribute("data-copy-kind") === "server-url") {
|
|
1283
|
+
const base = button.getAttribute("data-copy-base") || value;
|
|
1284
|
+
const params = new URLSearchParams(window.location.search);
|
|
1285
|
+
const token = params.get("codexflow_token") || params.get("token") || "";
|
|
1286
|
+
value = token ? base + "?codexflow_token=" + encodeURIComponent(token) : base;
|
|
1287
|
+
}
|
|
1288
|
+
try {
|
|
1289
|
+
await navigator.clipboard.writeText(value);
|
|
1290
|
+
button.textContent = "Copied";
|
|
1291
|
+
setTimeout(() => { button.textContent = "Copy"; }, 1400);
|
|
1292
|
+
} catch {
|
|
1293
|
+
button.textContent = "Select";
|
|
1294
|
+
}
|
|
1295
|
+
});
|
|
1296
|
+
});
|
|
1297
|
+
const profileForm = document.querySelector("[data-profile-form]");
|
|
1298
|
+
const tunnelSelect = document.querySelector("[data-tunnel-select]");
|
|
1299
|
+
const hostnameInput = document.querySelector("[data-hostname-input]");
|
|
1300
|
+
const hostnameHelp = document.querySelector("[data-hostname-help]");
|
|
1301
|
+
const tokenEnabled = ${config.authToken ? "true" : "false"};
|
|
1302
|
+
function serverPreviewFor(hostname) {
|
|
1303
|
+
const clean = String(hostname || "").trim().replace(/^https?:\\/\\//, "").replace(/\\/mcp\\/?$/, "").replace(/\\/+$/, "");
|
|
1304
|
+
if (!clean) return "";
|
|
1305
|
+
return "https://" + clean + "/mcp" + (tokenEnabled ? "?codexflow_token=<redacted>" : "");
|
|
1306
|
+
}
|
|
1307
|
+
function updateTunnelHelp() {
|
|
1308
|
+
if (!tunnelSelect || !hostnameInput || !hostnameHelp) return;
|
|
1309
|
+
const tunnel = tunnelSelect.value;
|
|
1310
|
+
const ngrokHost = tunnelSelect.getAttribute("data-ngrok-hostname") || "";
|
|
1311
|
+
const cloudflareHost = tunnelSelect.getAttribute("data-cloudflare-hostname") || "";
|
|
1312
|
+
if (tunnel === "ngrok" && !hostnameInput.value && ngrokHost) {
|
|
1313
|
+
hostnameInput.value = ngrokHost;
|
|
1314
|
+
hostnameInput.setAttribute("data-autofilled", "1");
|
|
1315
|
+
}
|
|
1316
|
+
if (tunnel === "cloudflare-named" && !hostnameInput.value && cloudflareHost) {
|
|
1317
|
+
hostnameInput.value = cloudflareHost;
|
|
1318
|
+
hostnameInput.setAttribute("data-autofilled", "1");
|
|
1319
|
+
}
|
|
1320
|
+
if ((tunnel === "cloudflare" || tunnel === "none") && hostnameInput.getAttribute("data-autofilled") === "1") {
|
|
1321
|
+
hostnameInput.value = "";
|
|
1322
|
+
hostnameInput.setAttribute("data-autofilled", "0");
|
|
1323
|
+
}
|
|
1324
|
+
const preview = serverPreviewFor(hostnameInput.value);
|
|
1325
|
+
if (tunnel === "cloudflare") {
|
|
1326
|
+
hostnameHelp.textContent = "Cloudflare quick tunnel generates the public URL at launch and this page shows it when the launcher reports it.";
|
|
1327
|
+
} else if (tunnel === "ngrok") {
|
|
1328
|
+
hostnameHelp.textContent = preview ? "Next Server URL preview: " + preview : "Enter the reserved ngrok domain from your local ngrok setup.";
|
|
1329
|
+
} else if (tunnel === "cloudflare-named") {
|
|
1330
|
+
hostnameHelp.textContent = preview ? "Next Server URL preview: " + preview : "Enter the hostname routed to your Cloudflare named tunnel.";
|
|
1331
|
+
} else if (tunnel === "tailscale") {
|
|
1332
|
+
hostnameHelp.textContent = preview ? "Next Server URL preview: " + preview : "Enter the Tailscale Funnel hostname for this device.";
|
|
1333
|
+
} else {
|
|
1334
|
+
hostnameHelp.textContent = "Local-only mode does not expose a public ChatGPT Server URL.";
|
|
1335
|
+
}
|
|
1336
|
+
}
|
|
1337
|
+
tunnelSelect?.addEventListener("change", updateTunnelHelp);
|
|
1338
|
+
hostnameInput?.addEventListener("input", () => {
|
|
1339
|
+
hostnameInput.setAttribute("data-autofilled", "0");
|
|
1340
|
+
updateTunnelHelp();
|
|
1341
|
+
});
|
|
1342
|
+
updateTunnelHelp();
|
|
1343
|
+
if (profileForm) {
|
|
1344
|
+
profileForm.addEventListener("submit", async (event) => {
|
|
1345
|
+
event.preventDefault();
|
|
1346
|
+
const form = event.currentTarget;
|
|
1347
|
+
const status = document.querySelector("[data-profile-status]");
|
|
1348
|
+
const data = Object.fromEntries(new FormData(form).entries());
|
|
1349
|
+
const payload = {
|
|
1350
|
+
tunnel: data.tunnel,
|
|
1351
|
+
hostname: data.hostname,
|
|
1352
|
+
tunnelName: data.tunnelName,
|
|
1353
|
+
ngrokConfig: data.ngrokConfig,
|
|
1354
|
+
cloudflareConfig: data.cloudflareConfig,
|
|
1355
|
+
cloudflareTokenFile: data.cloudflareTokenFile,
|
|
1356
|
+
port: Number(data.port),
|
|
1357
|
+
mode: data.mode,
|
|
1358
|
+
bash: data.bash,
|
|
1359
|
+
write: data.write,
|
|
1360
|
+
toolMode: data.toolMode,
|
|
1361
|
+
toolCards: Boolean(form.elements.toolCards?.checked),
|
|
1362
|
+
codexSessions: data.codexSessions,
|
|
1363
|
+
codexDir: data.codexDir,
|
|
1364
|
+
bashSession: data.bashSession,
|
|
1365
|
+
requireBashSession: Boolean(form.elements.requireBashSession?.checked),
|
|
1366
|
+
noInstallCloudflared: Boolean(form.elements.noInstallCloudflared?.checked)
|
|
1367
|
+
};
|
|
1368
|
+
if (status) status.textContent = "Saving...";
|
|
1369
|
+
try {
|
|
1370
|
+
const response = await fetch("/admin/profile" + window.location.search, {
|
|
1371
|
+
method: "POST",
|
|
1372
|
+
headers: { "content-type": "application/json" },
|
|
1373
|
+
body: JSON.stringify(payload)
|
|
1374
|
+
});
|
|
1375
|
+
const result = await response.json().catch(() => ({}));
|
|
1376
|
+
if (!response.ok) throw new Error(result.error?.message || "Save failed");
|
|
1377
|
+
if (status) status.textContent = "Saved. Restart CodexFlow for these profile settings to apply.";
|
|
1378
|
+
} catch (error) {
|
|
1379
|
+
if (status) status.textContent = error instanceof Error ? error.message : "Save failed";
|
|
1380
|
+
}
|
|
1381
|
+
});
|
|
1382
|
+
}
|
|
1383
|
+
</script>
|
|
1384
|
+
</body>
|
|
1385
|
+
</html>`;
|
|
1386
|
+
}
|
|
1387
|
+
async function main() {
|
|
1388
|
+
const argv = process.argv.slice(2);
|
|
1389
|
+
if (argv.includes("--version") || argv.includes("-v") || argv[0] === "version") {
|
|
1390
|
+
console.log(CODEXFLOW_VERSION);
|
|
1391
|
+
return;
|
|
1392
|
+
}
|
|
1393
|
+
if (argv.includes("--help") || argv[0] === "help") {
|
|
1394
|
+
printHelp();
|
|
1395
|
+
return;
|
|
1396
|
+
}
|
|
1397
|
+
const config = loadConfig();
|
|
1398
|
+
if (config.requireHttpToken && !config.authToken) {
|
|
1399
|
+
throw new Error("CODEXFLOW_HTTP_TOKEN is required for this HTTP binding. " +
|
|
1400
|
+
"Set CODEXFLOW_HTTP_TOKEN, use `codexflow` to generate one, " +
|
|
1401
|
+
"or set CODEXFLOW_ALLOW_NO_HTTP_TOKEN=1 only for a trusted local-only setup.");
|
|
1402
|
+
}
|
|
1403
|
+
const app = express();
|
|
1404
|
+
const logRequests = process.env.CODEXFLOW_LOG_REQUESTS === "1";
|
|
1405
|
+
function tokenMatches(value) {
|
|
1406
|
+
if (!config.authToken || typeof value !== "string")
|
|
1407
|
+
return false;
|
|
1408
|
+
const expected = Buffer.from(config.authToken);
|
|
1409
|
+
const actual = Buffer.from(value);
|
|
1410
|
+
return expected.length === actual.length && timingSafeEqual(expected, actual);
|
|
1411
|
+
}
|
|
1412
|
+
const adminRateWindow = new Map();
|
|
1413
|
+
function adminRateLimit(req, res, next) {
|
|
1414
|
+
const now = Date.now();
|
|
1415
|
+
const key = req.ip || req.socket.remoteAddress || "local";
|
|
1416
|
+
const current = adminRateWindow.get(key);
|
|
1417
|
+
if (!current || current.resetAt <= now) {
|
|
1418
|
+
adminRateWindow.set(key, { count: 1, resetAt: now + 60_000 });
|
|
1419
|
+
next();
|
|
1420
|
+
return;
|
|
1421
|
+
}
|
|
1422
|
+
current.count += 1;
|
|
1423
|
+
if (current.count > 30) {
|
|
1424
|
+
jsonError(res, 429, "rate_limited", "Too many profile save attempts. Try again in a minute.");
|
|
1425
|
+
return;
|
|
1426
|
+
}
|
|
1427
|
+
next();
|
|
1428
|
+
}
|
|
1429
|
+
function adminBodyLimit(req, res, next) {
|
|
1430
|
+
const length = Number(req.headers["content-length"] ?? 0);
|
|
1431
|
+
if (Number.isFinite(length) && length > 32_768) {
|
|
1432
|
+
jsonError(res, 413, "payload_too_large", "Profile request body is too large.");
|
|
1433
|
+
return;
|
|
1434
|
+
}
|
|
1435
|
+
next();
|
|
1436
|
+
}
|
|
1437
|
+
app.use((req, res, next) => {
|
|
1438
|
+
if (!logRequests) {
|
|
1439
|
+
next();
|
|
1440
|
+
return;
|
|
1441
|
+
}
|
|
1442
|
+
const started = Date.now();
|
|
1443
|
+
console.error(`[CodexFlow] ${req.method} ${req.path} received`);
|
|
1444
|
+
res.on("finish", () => {
|
|
1445
|
+
console.error(`[CodexFlow] ${req.method} ${req.path} -> ${res.statusCode} ${Date.now() - started}ms`);
|
|
1446
|
+
});
|
|
1447
|
+
next();
|
|
1448
|
+
});
|
|
1449
|
+
app.use(cors({ exposedHeaders: ["Mcp-Session-Id"] }));
|
|
1450
|
+
app.get("/favicon.ico", (_req, res) => {
|
|
1451
|
+
res.setHeader("Cache-Control", "public, max-age=86400");
|
|
1452
|
+
res.type("image/svg+xml").send(LOCAL_FAVICON);
|
|
1453
|
+
});
|
|
1454
|
+
app.use((req, res, next) => {
|
|
1455
|
+
if (!config.authToken) {
|
|
1456
|
+
next();
|
|
1457
|
+
return;
|
|
1458
|
+
}
|
|
1459
|
+
const bearer = req.headers.authorization?.match(/^Bearer\s+(.+)$/i)?.[1]?.trim();
|
|
1460
|
+
const queryToken = typeof req.query.codexflow_token === "string"
|
|
1461
|
+
? req.query.codexflow_token
|
|
1462
|
+
: typeof req.query.token === "string"
|
|
1463
|
+
? req.query.token
|
|
1464
|
+
: undefined;
|
|
1465
|
+
if (!tokenMatches(bearer) && !tokenMatches(queryToken)) {
|
|
1466
|
+
res.status(401).send("Unauthorized");
|
|
1467
|
+
return;
|
|
1468
|
+
}
|
|
1469
|
+
next();
|
|
1470
|
+
});
|
|
1471
|
+
const transports = new Map();
|
|
1472
|
+
const sessionIdPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
1473
|
+
function requestSessionId(req) {
|
|
1474
|
+
const value = req.headers["mcp-session-id"];
|
|
1475
|
+
return Array.isArray(value) ? value[0] : value;
|
|
1476
|
+
}
|
|
1477
|
+
function sendSessionError(res, sessionId) {
|
|
1478
|
+
const missing = !sessionId;
|
|
1479
|
+
const malformed = Boolean(sessionId && !sessionIdPattern.test(sessionId));
|
|
1480
|
+
res.status(missing || malformed ? 400 : 404).json({
|
|
1481
|
+
jsonrpc: "2.0",
|
|
1482
|
+
error: missing
|
|
1483
|
+
? { code: -32000, message: "Bad Request: Mcp-Session-Id header is required" }
|
|
1484
|
+
: malformed
|
|
1485
|
+
? { code: -32000, message: "Bad Request: invalid MCP session id" }
|
|
1486
|
+
: { code: -32001, message: "Session not found" },
|
|
1487
|
+
id: null
|
|
1488
|
+
});
|
|
1489
|
+
}
|
|
1490
|
+
function closeTransport(record) {
|
|
1491
|
+
void record.transport.close?.();
|
|
1492
|
+
}
|
|
1493
|
+
function pruneTransports() {
|
|
1494
|
+
const now = Date.now();
|
|
1495
|
+
for (const [sessionId, record] of transports) {
|
|
1496
|
+
if (now - record.lastSeenAt > config.httpSessionTtlMs) {
|
|
1497
|
+
transports.delete(sessionId);
|
|
1498
|
+
closeTransport(record);
|
|
1499
|
+
}
|
|
1500
|
+
}
|
|
1501
|
+
while (transports.size > config.maxHttpSessions) {
|
|
1502
|
+
const oldest = [...transports.entries()].sort((a, b) => a[1].lastSeenAt - b[1].lastSeenAt)[0];
|
|
1503
|
+
if (!oldest)
|
|
1504
|
+
break;
|
|
1505
|
+
transports.delete(oldest[0]);
|
|
1506
|
+
closeTransport(oldest[1]);
|
|
1507
|
+
}
|
|
1508
|
+
}
|
|
1509
|
+
function getTransport(sessionId) {
|
|
1510
|
+
if (!sessionId || !sessionIdPattern.test(sessionId))
|
|
1511
|
+
return undefined;
|
|
1512
|
+
pruneTransports();
|
|
1513
|
+
const record = transports.get(sessionId);
|
|
1514
|
+
if (!record)
|
|
1515
|
+
return undefined;
|
|
1516
|
+
record.lastSeenAt = Date.now();
|
|
1517
|
+
return record.transport;
|
|
1518
|
+
}
|
|
1519
|
+
const pruneTimer = setInterval(pruneTransports, Math.min(config.httpSessionTtlMs, 60_000));
|
|
1520
|
+
pruneTimer.unref();
|
|
1521
|
+
app.get("/", (_req, res) => {
|
|
1522
|
+
res.type("html").send(onboardingPage(config));
|
|
1523
|
+
});
|
|
1524
|
+
app.get("/setup", (_req, res) => {
|
|
1525
|
+
res.type("html").send(onboardingPage(config));
|
|
1526
|
+
});
|
|
1527
|
+
app.get("/healthz", (_req, res) => {
|
|
1528
|
+
res.json({
|
|
1529
|
+
ok: true,
|
|
1530
|
+
name: "CodexFlow",
|
|
1531
|
+
defaultRoot: config.defaultRoot,
|
|
1532
|
+
allowedRoots: config.allowedRoots,
|
|
1533
|
+
bashMode: config.bashMode,
|
|
1534
|
+
bashTranscript: config.bashTranscript,
|
|
1535
|
+
bashSessionId: config.bashSessionId ?? null,
|
|
1536
|
+
requireBashSession: config.requireBashSession,
|
|
1537
|
+
codexSessions: config.codexSessions,
|
|
1538
|
+
writeMode: config.writeMode,
|
|
1539
|
+
toolMode: config.toolMode,
|
|
1540
|
+
widgetDomain: config.widgetDomain,
|
|
1541
|
+
contextDir: config.contextDir,
|
|
1542
|
+
authEnabled: Boolean(config.authToken),
|
|
1543
|
+
authRequired: Boolean(config.authToken)
|
|
1544
|
+
});
|
|
1545
|
+
});
|
|
1546
|
+
app.get("/admin/profile", (_req, res) => {
|
|
1547
|
+
res.json(profileResponse(config));
|
|
1548
|
+
});
|
|
1549
|
+
app.post("/admin/profile", adminRateLimit, adminBodyLimit, express.json({ limit: "32kb" }), (req, res) => {
|
|
1550
|
+
const parsed = AdminProfilePatch.safeParse(req.body ?? {});
|
|
1551
|
+
if (!parsed.success) {
|
|
1552
|
+
jsonError(res, 400, "invalid_profile", "Invalid profile settings.", parsed.error.flatten());
|
|
1553
|
+
return;
|
|
1554
|
+
}
|
|
1555
|
+
try {
|
|
1556
|
+
const existing = readWorkspaceProfile(config.defaultRoot);
|
|
1557
|
+
const payload = buildProfilePayload(config, existing, parsed.data);
|
|
1558
|
+
const profilePath = saveWorkspaceProfile(config.defaultRoot, payload);
|
|
1559
|
+
res.json({
|
|
1560
|
+
...profileResponse(config),
|
|
1561
|
+
saved: true,
|
|
1562
|
+
profile_path: profilePath,
|
|
1563
|
+
message: "Saved. Restart CodexFlow for these profile settings to apply."
|
|
1564
|
+
});
|
|
1565
|
+
}
|
|
1566
|
+
catch (error) {
|
|
1567
|
+
jsonError(res, 400, "invalid_profile", error instanceof Error ? error.message : String(error));
|
|
1568
|
+
}
|
|
1569
|
+
});
|
|
1570
|
+
app.all("/admin/profile", (_req, res) => {
|
|
1571
|
+
jsonError(res, 405, "method_not_allowed", "Use GET or POST for /admin/profile.");
|
|
1572
|
+
});
|
|
1573
|
+
app.post("/mcp", express.json({ limit: "20mb" }), async (req, res) => {
|
|
1574
|
+
try {
|
|
1575
|
+
const sessionId = requestSessionId(req);
|
|
1576
|
+
let transport;
|
|
1577
|
+
const existingTransport = getTransport(sessionId);
|
|
1578
|
+
if (existingTransport) {
|
|
1579
|
+
transport = existingTransport;
|
|
1580
|
+
}
|
|
1581
|
+
else if (!sessionId && isInitializeRequest(req.body)) {
|
|
1582
|
+
transport = new StreamableHTTPServerTransport({
|
|
1583
|
+
sessionIdGenerator: () => randomUUID(),
|
|
1584
|
+
onsessioninitialized: (newSessionId) => {
|
|
1585
|
+
pruneTransports();
|
|
1586
|
+
transports.set(newSessionId, {
|
|
1587
|
+
transport,
|
|
1588
|
+
createdAt: Date.now(),
|
|
1589
|
+
lastSeenAt: Date.now()
|
|
1590
|
+
});
|
|
1591
|
+
pruneTransports();
|
|
1592
|
+
}
|
|
1593
|
+
});
|
|
1594
|
+
transport.onclose = () => {
|
|
1595
|
+
const closedSessionId = transport.sessionId;
|
|
1596
|
+
if (closedSessionId)
|
|
1597
|
+
transports.delete(closedSessionId);
|
|
1598
|
+
};
|
|
1599
|
+
const server = createCodexFlowServer(config);
|
|
1600
|
+
await server.connect(transport);
|
|
1601
|
+
}
|
|
1602
|
+
else {
|
|
1603
|
+
sendSessionError(res, sessionId);
|
|
1604
|
+
return;
|
|
1605
|
+
}
|
|
1606
|
+
await transport.handleRequest(req, res, req.body);
|
|
1607
|
+
}
|
|
1608
|
+
catch (error) {
|
|
1609
|
+
console.error(error instanceof Error ? error.stack ?? error.message : String(error));
|
|
1610
|
+
if (!res.headersSent) {
|
|
1611
|
+
res.status(500).json({
|
|
1612
|
+
jsonrpc: "2.0",
|
|
1613
|
+
error: { code: -32603, message: "Internal CodexFlow MCP error. Check the local terminal for details." },
|
|
1614
|
+
id: null
|
|
1615
|
+
});
|
|
1616
|
+
}
|
|
1617
|
+
}
|
|
1618
|
+
});
|
|
1619
|
+
const handleSessionRequest = async (req, res) => {
|
|
1620
|
+
const sessionId = requestSessionId(req);
|
|
1621
|
+
const transport = getTransport(sessionId);
|
|
1622
|
+
if (!transport) {
|
|
1623
|
+
sendSessionError(res, sessionId);
|
|
1624
|
+
return;
|
|
1625
|
+
}
|
|
1626
|
+
await transport.handleRequest(req, res);
|
|
1627
|
+
};
|
|
1628
|
+
app.get("/mcp", handleSessionRequest);
|
|
1629
|
+
app.delete("/mcp", handleSessionRequest);
|
|
1630
|
+
app.use((error, req, res, next) => {
|
|
1631
|
+
if (!error || typeof error !== "object" || !("type" in error)) {
|
|
1632
|
+
next(error);
|
|
1633
|
+
return;
|
|
1634
|
+
}
|
|
1635
|
+
const type = String(error.type ?? "");
|
|
1636
|
+
if (type !== "entity.parse.failed" && type !== "entity.too.large") {
|
|
1637
|
+
next(error);
|
|
1638
|
+
return;
|
|
1639
|
+
}
|
|
1640
|
+
const status = type === "entity.too.large" ? 413 : 400;
|
|
1641
|
+
if (req.path === "/mcp") {
|
|
1642
|
+
res.status(status).json({
|
|
1643
|
+
jsonrpc: "2.0",
|
|
1644
|
+
error: {
|
|
1645
|
+
code: type === "entity.too.large" ? -32000 : -32700,
|
|
1646
|
+
message: type === "entity.too.large" ? "Payload too large." : "Parse error."
|
|
1647
|
+
},
|
|
1648
|
+
id: null
|
|
1649
|
+
});
|
|
1650
|
+
return;
|
|
1651
|
+
}
|
|
1652
|
+
if (req.path === "/admin/profile") {
|
|
1653
|
+
jsonError(res, status, type === "entity.too.large" ? "payload_too_large" : "invalid_json", type === "entity.too.large" ? "Request body is too large." : "Request body must be valid JSON.");
|
|
1654
|
+
return;
|
|
1655
|
+
}
|
|
1656
|
+
next(error);
|
|
1657
|
+
});
|
|
1658
|
+
app.listen(config.port, config.host, () => {
|
|
1659
|
+
console.error(`[CodexFlow] HTTP MCP listening on http://${config.host}:${config.port}/mcp`);
|
|
1660
|
+
console.error(`[CodexFlow] defaultRoot=${config.defaultRoot}`);
|
|
1661
|
+
console.error(`[CodexFlow] allowedRoots=${config.allowedRoots.join(", ")}`);
|
|
1662
|
+
console.error(`[CodexFlow] bashMode=${config.bashMode}`);
|
|
1663
|
+
console.error(`[CodexFlow] writeMode=${config.writeMode}`);
|
|
1664
|
+
console.error(`[CodexFlow] widgetDomain=${config.widgetDomain}`);
|
|
1665
|
+
});
|
|
1666
|
+
}
|
|
1667
|
+
main().catch((error) => {
|
|
1668
|
+
console.error(error instanceof Error ? error.stack ?? error.message : String(error));
|
|
1669
|
+
process.exit(1);
|
|
1670
|
+
});
|
|
1671
|
+
//# sourceMappingURL=http.js.map
|