@roamcode.ai/server 1.0.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/LICENSE +21 -0
- package/dist/auth.d.ts +63 -0
- package/dist/auth.js +133 -0
- package/dist/claude-auth-service.d.ts +76 -0
- package/dist/claude-auth-service.js +217 -0
- package/dist/claude-latest-service.d.ts +34 -0
- package/dist/claude-latest-service.js +61 -0
- package/dist/config.d.ts +78 -0
- package/dist/config.js +59 -0
- package/dist/data-dir.d.ts +42 -0
- package/dist/data-dir.js +70 -0
- package/dist/diag.d.ts +43 -0
- package/dist/diag.js +83 -0
- package/dist/fs-service.d.ts +90 -0
- package/dist/fs-service.js +290 -0
- package/dist/index.d.ts +82 -0
- package/dist/index.js +44 -0
- package/dist/managed-runtime.d.ts +51 -0
- package/dist/managed-runtime.js +411 -0
- package/dist/managed-update-helper.d.ts +2 -0
- package/dist/managed-update-helper.js +34 -0
- package/dist/mcp-send.d.ts +33 -0
- package/dist/mcp-send.js +107 -0
- package/dist/origin-check.d.ts +37 -0
- package/dist/origin-check.js +101 -0
- package/dist/pane-status.d.ts +61 -0
- package/dist/pane-status.js +145 -0
- package/dist/providers/claude-metadata-service.d.ts +58 -0
- package/dist/providers/claude-metadata-service.js +352 -0
- package/dist/providers/claude-provider.d.ts +11 -0
- package/dist/providers/claude-provider.js +166 -0
- package/dist/providers/codex-activity.d.ts +21 -0
- package/dist/providers/codex-activity.js +122 -0
- package/dist/providers/codex-app-server-client.d.ts +90 -0
- package/dist/providers/codex-app-server-client.js +485 -0
- package/dist/providers/codex-latest-service.d.ts +50 -0
- package/dist/providers/codex-latest-service.js +174 -0
- package/dist/providers/codex-metadata-service.d.ts +161 -0
- package/dist/providers/codex-metadata-service.js +686 -0
- package/dist/providers/codex-profile-client.d.ts +16 -0
- package/dist/providers/codex-profile-client.js +52 -0
- package/dist/providers/codex-profile-security.d.ts +23 -0
- package/dist/providers/codex-profile-security.js +161 -0
- package/dist/providers/codex-provider.d.ts +15 -0
- package/dist/providers/codex-provider.js +174 -0
- package/dist/providers/codex-thread-coordinator.d.ts +18 -0
- package/dist/providers/codex-thread-coordinator.js +93 -0
- package/dist/providers/codex-thread-persistence.d.ts +9 -0
- package/dist/providers/codex-thread-persistence.js +45 -0
- package/dist/providers/codex-thread-resolver.d.ts +59 -0
- package/dist/providers/codex-thread-resolver.js +322 -0
- package/dist/providers/options.d.ts +7 -0
- package/dist/providers/options.js +155 -0
- package/dist/providers/provider-artifacts.d.ts +3 -0
- package/dist/providers/provider-artifacts.js +30 -0
- package/dist/providers/registry.d.ts +7 -0
- package/dist/providers/registry.js +23 -0
- package/dist/providers/types.d.ts +95 -0
- package/dist/providers/types.js +8 -0
- package/dist/push-dispatch.d.ts +81 -0
- package/dist/push-dispatch.js +100 -0
- package/dist/push-store.d.ts +25 -0
- package/dist/push-store.js +79 -0
- package/dist/rate-limit.d.ts +52 -0
- package/dist/rate-limit.js +72 -0
- package/dist/server-config.d.ts +60 -0
- package/dist/server-config.js +77 -0
- package/dist/service-install.d.ts +60 -0
- package/dist/service-install.js +221 -0
- package/dist/session-defaults.d.ts +26 -0
- package/dist/session-defaults.js +60 -0
- package/dist/session-store.d.ts +81 -0
- package/dist/session-store.js +654 -0
- package/dist/start.d.ts +31 -0
- package/dist/start.js +372 -0
- package/dist/static-routes.d.ts +101 -0
- package/dist/static-routes.js +188 -0
- package/dist/terminal-capability.d.ts +5 -0
- package/dist/terminal-capability.js +27 -0
- package/dist/terminal-manager.d.ts +224 -0
- package/dist/terminal-manager.js +917 -0
- package/dist/terminal-process.d.ts +85 -0
- package/dist/terminal-process.js +238 -0
- package/dist/terminal-shared.d.ts +36 -0
- package/dist/terminal-shared.js +43 -0
- package/dist/tmux-list.d.ts +11 -0
- package/dist/tmux-list.js +39 -0
- package/dist/transport.d.ts +123 -0
- package/dist/transport.js +1559 -0
- package/dist/updater.d.ts +161 -0
- package/dist/updater.js +451 -0
- package/dist/usage-service.d.ts +118 -0
- package/dist/usage-service.js +173 -0
- package/dist/vapid.d.ts +17 -0
- package/dist/vapid.js +31 -0
- package/dist/web-push-send.d.ts +20 -0
- package/dist/web-push-send.js +21 -0
- package/dist/ws-ticket.d.ts +47 -0
- package/dist/ws-ticket.js +62 -0
- package/package.json +55 -0
package/dist/start.js
ADDED
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
import { pathToFileURL, fileURLToPath } from "node:url";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
4
|
+
import { createServer } from "./transport.js";
|
|
5
|
+
import { loadServerConfig, assertConfigAllowsStart, isLoopbackAddress } from "./server-config.js";
|
|
6
|
+
import { ensureDataDir, resolveAccessToken } from "./data-dir.js";
|
|
7
|
+
import { openSessionStore } from "./session-store.js";
|
|
8
|
+
import { resolveVapidKeys } from "./vapid.js";
|
|
9
|
+
import { openPushStore } from "./push-store.js";
|
|
10
|
+
import { createWebPushSend } from "./web-push-send.js";
|
|
11
|
+
import { createPushDispatcher } from "./push-dispatch.js";
|
|
12
|
+
import { createUsageService } from "./usage-service.js";
|
|
13
|
+
import { createClaudeAuthService } from "./claude-auth-service.js";
|
|
14
|
+
import { createClaudeLatestService } from "./claude-latest-service.js";
|
|
15
|
+
import { createClaudeVersionProbe, defaultRunClaudeVersion } from "./diag.js";
|
|
16
|
+
import { classifierVersionWarning } from "./pane-status.js";
|
|
17
|
+
import { ProviderRegistry } from "./providers/registry.js";
|
|
18
|
+
import { createClaudeProvider } from "./providers/claude-provider.js";
|
|
19
|
+
import { createCodexProvider } from "./providers/codex-provider.js";
|
|
20
|
+
import { CodexAppServerClient } from "./providers/codex-app-server-client.js";
|
|
21
|
+
import { CodexMetadataService } from "./providers/codex-metadata-service.js";
|
|
22
|
+
import { ClaudeMetadataService, createClaudeMetadataRunner } from "./providers/claude-metadata-service.js";
|
|
23
|
+
import { createCodexProfileClientLifecycle } from "./providers/codex-profile-client.js";
|
|
24
|
+
import { createCodexThreadInventory, CodexThreadResolver } from "./providers/codex-thread-resolver.js";
|
|
25
|
+
import { CodexLatestService } from "./providers/codex-latest-service.js";
|
|
26
|
+
import { execFile } from "node:child_process";
|
|
27
|
+
import { createUpdater } from "./updater.js";
|
|
28
|
+
export function providerPreflightWarning(name, availability) {
|
|
29
|
+
if (availability.terminalAvailable)
|
|
30
|
+
return undefined;
|
|
31
|
+
return (`\n⚠ ${name} CLI not found or not runnable — new ${name} sessions will FAIL until this is fixed.\n` +
|
|
32
|
+
` Install ${name} and make sure its executable is on this server's PATH, then authenticate it on the host.\n`);
|
|
33
|
+
}
|
|
34
|
+
export async function runProviderPreflight(providers, warn = (message) => console.warn(message)) {
|
|
35
|
+
await Promise.all(providers.map(async (provider) => {
|
|
36
|
+
let availability;
|
|
37
|
+
try {
|
|
38
|
+
availability = await provider.probe();
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
availability = { terminalAvailable: false, metadataAvailable: false };
|
|
42
|
+
}
|
|
43
|
+
const message = providerPreflightWarning(provider.name, availability);
|
|
44
|
+
if (message)
|
|
45
|
+
warn(message);
|
|
46
|
+
}));
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* STARTUP PREFLIGHT (#7): format a prominent, actionable boot warning when `claude --version` couldn't
|
|
50
|
+
* run. The server still boots (sessions just won't start until the operator fixes it) — this is purely
|
|
51
|
+
* to surface WHY in the logs immediately, like the better-sqlite3 fallback warning. Returns `undefined`
|
|
52
|
+
* when claude is available (no warning). PURE so it's unit-testable without spawning.
|
|
53
|
+
*/
|
|
54
|
+
export function claudePreflightWarning(availability) {
|
|
55
|
+
if (availability.available)
|
|
56
|
+
return undefined;
|
|
57
|
+
return ("\n⚠ `claude` CLI not found or not runnable — new sessions will FAIL until this is fixed.\n" +
|
|
58
|
+
" Install Claude Code and make sure `claude` is on this server's PATH, then authenticate by\n" +
|
|
59
|
+
" running `claude` once in a terminal on the host (there is no remote login).\n" +
|
|
60
|
+
" (If it IS installed, the service's PATH may not include it — see the README troubleshooting.)\n");
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Run the startup preflight: best-effort probe `claude --version` and print {@link claudePreflightWarning}
|
|
64
|
+
* if it's missing/failing. NEVER throws and NEVER blocks boot — a hung/slow claude can't stall startup
|
|
65
|
+
* (the probe is short-timeout-guarded). Injectable probe + log sink so it's testable without a spawn.
|
|
66
|
+
*/
|
|
67
|
+
export async function runClaudePreflight(probe, warn = (m) => console.warn(m)) {
|
|
68
|
+
let availability;
|
|
69
|
+
try {
|
|
70
|
+
availability = await probe.get();
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
availability = { available: false }; // a thrown probe is treated as unavailable (defensive; probe never throws)
|
|
74
|
+
}
|
|
75
|
+
const message = claudePreflightWarning(availability);
|
|
76
|
+
if (message)
|
|
77
|
+
warn(message);
|
|
78
|
+
}
|
|
79
|
+
export async function startServer(env = process.env) {
|
|
80
|
+
const config = loadServerConfig(env);
|
|
81
|
+
// First-run token (spec §9): use ACCESS_TOKEN if set, else the persisted token, else generate.
|
|
82
|
+
// EXPLICIT OPT-OUT: NO_TOKEN=1 keeps the Plan-3 tokenless dev path (no token generated/stored/
|
|
83
|
+
// required) and only RUNS on a loopback bind (assertConfigAllowsStart enforces that below).
|
|
84
|
+
// SECURITY: a token is auto-generated only when the bind is loopback OR a token is already
|
|
85
|
+
// configured/persisted. A FIRST non-loopback bind with no token is left tokenless so
|
|
86
|
+
// assertConfigAllowsStart refuses to start — we never silently mint a secret for a public bind.
|
|
87
|
+
ensureDataDir(config.dataDir);
|
|
88
|
+
const loopback = isLoopbackAddress(config.bindAddress);
|
|
89
|
+
let token;
|
|
90
|
+
let generated = false;
|
|
91
|
+
const tokenless = env.NO_TOKEN === "1";
|
|
92
|
+
const mayResolveToken = !tokenless && (loopback || config.accessToken !== undefined);
|
|
93
|
+
if (mayResolveToken) {
|
|
94
|
+
const resolved = resolveAccessToken({ configured: config.accessToken, dataDir: config.dataDir });
|
|
95
|
+
token = resolved.token;
|
|
96
|
+
generated = resolved.generated;
|
|
97
|
+
config.accessToken = token;
|
|
98
|
+
}
|
|
99
|
+
assertConfigAllowsStart(config); // refuses a non-loopback bind that still has no token
|
|
100
|
+
const store = openSessionStore({ dbPath: join(config.dataDir, "sessions.db") });
|
|
101
|
+
// LOUD store-fallback warning: the store silently falls back to a non-durable in-memory Map when the
|
|
102
|
+
// native better-sqlite3 module can't load. That means sessions are LOST on every restart (incl. the OTA
|
|
103
|
+
// restart) — a silent data-durability footgun. Warn prominently + actionably so an operator notices and
|
|
104
|
+
// rebuilds the native module. `storeMode` is threaded to /diag for fleet observability.
|
|
105
|
+
const storeMode = store.mode;
|
|
106
|
+
if (storeMode === "memory-fallback") {
|
|
107
|
+
console.warn("\n⚠ better-sqlite3 failed to load — sessions are NOT persisted across restarts (every restart, " +
|
|
108
|
+
"including an OTA update, starts empty).\n" +
|
|
109
|
+
" Rebuild the native module: pnpm -C packages/server rebuild better-sqlite3\n" +
|
|
110
|
+
" (or reinstall with native builds allowed: pnpm install && pnpm approve-builds better-sqlite3)\n");
|
|
111
|
+
}
|
|
112
|
+
// STARTUP PREFLIGHT (#7): one cached `claude --version` probe SHARED by the boot preflight below and the
|
|
113
|
+
// authed GET /diag (injected into createServer), so we spawn at most once. The probe is short-timeout-
|
|
114
|
+
// guarded and never throws.
|
|
115
|
+
const claudeVersionProbe = createClaudeVersionProbe({
|
|
116
|
+
run: defaultRunClaudeVersion(config.claude.claudeBin, env),
|
|
117
|
+
});
|
|
118
|
+
// The provider-labelled dual preflight is launched after both adapters are constructed below. This cached
|
|
119
|
+
// Claude probe is still shared with diagnostics and the classifier guard, so no duplicate spawn occurs.
|
|
120
|
+
// CLASSIFIER VERSION GUARD: the pane-status markers driving the rail's working/blocked/idle are tied to
|
|
121
|
+
// Claude Code's ENGLISH TUI strings (see pane-status.ts CLASSIFIER_TESTED_UP_TO). If the installed claude
|
|
122
|
+
// is NEWER than the version they were verified against, log ONE warning so a reworded TUI degrading every
|
|
123
|
+
// status to "idle" isn't a silent mystery. Shares the cached probe above (no extra spawn); never throws.
|
|
124
|
+
void claudeVersionProbe
|
|
125
|
+
.get()
|
|
126
|
+
.then((availability) => {
|
|
127
|
+
const warning = classifierVersionWarning(availability.version);
|
|
128
|
+
if (warning)
|
|
129
|
+
console.warn(`[roamcode] ⚠ ${warning}`);
|
|
130
|
+
})
|
|
131
|
+
.catch(() => {
|
|
132
|
+
/* the probe never rejects; defensive so the guard can never affect boot */
|
|
133
|
+
});
|
|
134
|
+
// Web Push (spec §1): VAPID keypair (persisted) + subscription store.
|
|
135
|
+
const vapid = resolveVapidKeys({ dataDir: config.dataDir });
|
|
136
|
+
const pushStore = openPushStore({ dbPath: join(config.dataDir, "push.db") });
|
|
137
|
+
// Away-from-desk dispatcher: fans "claude needs you / finished / sent a file / has a question" pushes out
|
|
138
|
+
// to every matching subscription (pruning dead ones on 404/410). The VAPID subject is a mailto:/https: URL
|
|
139
|
+
// the push service can contact (web-push REQUIRES it) — from ROAMCODE_VAPID_SUBJECT, else a sane
|
|
140
|
+
// default. Wrapped so a misconfigured subject (or a web-push init throw) DISABLES push rather than killing
|
|
141
|
+
// boot — an always-on server should keep serving even if the nice-to-have notifications can't send.
|
|
142
|
+
const vapidSubject = (env.ROAMCODE_VAPID_SUBJECT ?? env.REMOTE_CODER_VAPID_SUBJECT)?.trim() || "mailto:roamcode@localhost";
|
|
143
|
+
let pushDispatcher;
|
|
144
|
+
try {
|
|
145
|
+
pushDispatcher = createPushDispatcher({
|
|
146
|
+
pushStore,
|
|
147
|
+
send: createWebPushSend({ vapid, subject: vapidSubject }),
|
|
148
|
+
log: (m) => console.warn(`[roamcode] ${m}`),
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
catch (err) {
|
|
152
|
+
console.warn(`[roamcode] ⚠ web push disabled (${err.message}) — set a valid ROAMCODE_VAPID_SUBJECT`);
|
|
153
|
+
}
|
|
154
|
+
// The PWA is served from packages/web/dist when it exists (one-origin deploy). Guard the path:
|
|
155
|
+
// @fastify/static throws at register if `root` is missing (e.g. a dev tree with no `vite build` yet).
|
|
156
|
+
const candidateWebDir = env.WEB_DIR ?? defaultWebDir();
|
|
157
|
+
const webDir = candidateWebDir && existsSync(candidateWebDir) ? candidateWebDir : undefined;
|
|
158
|
+
// Claude usage bars (GET /usage): spawn `claude /usage` with the SAME claude bin the chat uses and
|
|
159
|
+
// the server's env (login session → subscription auth resolves). The service TTL-caches the parsed
|
|
160
|
+
// result so the rail's poll is cheap; a spawn/parse failure degrades to null (the UI hides the bars).
|
|
161
|
+
const usage = createUsageService({ claudeBin: config.claude.claudeBin, env });
|
|
162
|
+
// In-app Claude re-authentication (GET/POST /auth/*): wraps `claude auth login` so an expired server-side
|
|
163
|
+
// Claude login can be fixed from the app instead of SSHing in. Same claude bin + env as the terminal spawns.
|
|
164
|
+
const claudeAuth = createClaudeAuthService({ claudeBin: config.claude.claudeBin, env });
|
|
165
|
+
// Update awareness (GET /claude/version): the newest published claude version (npm dist-tag), TTL-cached,
|
|
166
|
+
// compared client-side against each session's spawn-time version to show a subtle "update available" hint.
|
|
167
|
+
const claudeLatest = createClaudeLatestService();
|
|
168
|
+
// Auxiliary model discovery is lazy: construction does not spawn Claude, and failures only degrade
|
|
169
|
+
// metadata routes or add a compatibility warning to session creation.
|
|
170
|
+
const claudeMetadata = new ClaudeMetadataService(createClaudeMetadataRunner({
|
|
171
|
+
claudeBin: config.claude.claudeBin,
|
|
172
|
+
cwd: config.fsRoot,
|
|
173
|
+
env,
|
|
174
|
+
}));
|
|
175
|
+
// Stable-only auxiliary app-server. The lazy RPC wrapper starts it only when a metadata route or exact
|
|
176
|
+
// thread-inventory probe is used; terminal construction never depends on successful initialization.
|
|
177
|
+
const codexClient = new CodexAppServerClient({ codexBin: config.codexBin, env });
|
|
178
|
+
const codexRpc = {
|
|
179
|
+
request: async (method, params, schema) => {
|
|
180
|
+
await codexClient.start();
|
|
181
|
+
return codexClient.request(method, params, schema);
|
|
182
|
+
},
|
|
183
|
+
onNotification: (listener) => codexClient.onNotification(listener),
|
|
184
|
+
};
|
|
185
|
+
const profileClient = createCodexProfileClientLifecycle({ codexBin: config.codexBin, env });
|
|
186
|
+
const codexMetadata = new CodexMetadataService(codexRpc, {
|
|
187
|
+
...(env.CODEX_HOME ? { codexHome: env.CODEX_HOME } : {}),
|
|
188
|
+
profileClient,
|
|
189
|
+
});
|
|
190
|
+
const codexCapabilityInventory = createCodexThreadInventory(codexRpc, { cwd: config.fsRoot });
|
|
191
|
+
const codexCapabilityProbe = {
|
|
192
|
+
get: () => codexMetadata.probeCapabilities(config.fsRoot, codexCapabilityInventory),
|
|
193
|
+
};
|
|
194
|
+
const codexLatest = new CodexLatestService({
|
|
195
|
+
runVersion: (args, options) => new Promise((resolve, reject) => {
|
|
196
|
+
execFile(config.codexBin, [...args], { env, timeout: options.timeoutMs, maxBuffer: options.maxOutputBytes, windowsHide: true }, (error, stdout, stderr) => {
|
|
197
|
+
if (error && error.code === "ENOENT") {
|
|
198
|
+
reject(error);
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
const errorCode = error && typeof error.code === "number" ? error.code : error ? 1 : 0;
|
|
202
|
+
resolve({
|
|
203
|
+
code: errorCode,
|
|
204
|
+
stdout: String(stdout),
|
|
205
|
+
stderr: String(stderr),
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
}),
|
|
209
|
+
detectProvenance: () => "unknown",
|
|
210
|
+
fetchNpmLatest: async (_packageName, options) => {
|
|
211
|
+
const response = await fetch("https://registry.npmjs.org/@openai%2Fcodex/latest", {
|
|
212
|
+
signal: AbortSignal.timeout(options.timeoutMs),
|
|
213
|
+
});
|
|
214
|
+
const text = await response.text();
|
|
215
|
+
if (!response.ok || Buffer.byteLength(text) > options.maxResponseBytes)
|
|
216
|
+
throw new Error("unavailable");
|
|
217
|
+
const parsed = JSON.parse(text);
|
|
218
|
+
if (typeof parsed.version !== "string")
|
|
219
|
+
throw new Error("unavailable");
|
|
220
|
+
return parsed.version;
|
|
221
|
+
},
|
|
222
|
+
});
|
|
223
|
+
const providers = new ProviderRegistry([
|
|
224
|
+
createClaudeProvider({
|
|
225
|
+
claudeBin: config.claude.claudeBin,
|
|
226
|
+
env,
|
|
227
|
+
probe: async () => {
|
|
228
|
+
const availability = await claudeVersionProbe.get();
|
|
229
|
+
return {
|
|
230
|
+
terminalAvailable: availability.available,
|
|
231
|
+
metadataAvailable: availability.available,
|
|
232
|
+
...(availability.version ? { version: availability.version } : {}),
|
|
233
|
+
};
|
|
234
|
+
},
|
|
235
|
+
}),
|
|
236
|
+
createCodexProvider({
|
|
237
|
+
codexBin: config.codexBin,
|
|
238
|
+
env,
|
|
239
|
+
validateProfile: codexMetadata.validateProfile,
|
|
240
|
+
probe: async () => {
|
|
241
|
+
try {
|
|
242
|
+
const version = await codexLatest.getVersion();
|
|
243
|
+
return { terminalAvailable: true, metadataAvailable: false, version: version.installed };
|
|
244
|
+
}
|
|
245
|
+
catch {
|
|
246
|
+
return { terminalAvailable: false, metadataAvailable: false };
|
|
247
|
+
}
|
|
248
|
+
},
|
|
249
|
+
}),
|
|
250
|
+
]);
|
|
251
|
+
void runProviderPreflight(providers.list().map((provider) => ({ name: provider.displayName, probe: () => provider.probe() })));
|
|
252
|
+
const result = createServer(config, {
|
|
253
|
+
store,
|
|
254
|
+
pushStore,
|
|
255
|
+
pushDispatcher,
|
|
256
|
+
webDir,
|
|
257
|
+
vapidPublicKey: vapid.publicKey,
|
|
258
|
+
usage,
|
|
259
|
+
claudeAuth,
|
|
260
|
+
claudeLatest,
|
|
261
|
+
storeMode,
|
|
262
|
+
// Share the boot-preflight probe with /diag so claude is spawned at most once for both.
|
|
263
|
+
claudeVersionProbe,
|
|
264
|
+
providers,
|
|
265
|
+
claudeMetadata,
|
|
266
|
+
codexMetadata,
|
|
267
|
+
codexCapabilityProbe,
|
|
268
|
+
codexLatest,
|
|
269
|
+
updater: createUpdater({ dataDir: config.dataDir, env }),
|
|
270
|
+
codexThreadResolver: (cwd) => new CodexThreadResolver({ inventory: createCodexThreadInventory(codexRpc, { cwd }) }),
|
|
271
|
+
disposeProviders: () => codexClient.stop(),
|
|
272
|
+
});
|
|
273
|
+
// LOUD boot warning when terminal mode is off (tmux/node-pty unavailable) — the server still serves, but
|
|
274
|
+
// EVERY session fails to start, and the cause is otherwise silent. Mirrors the claude/sqlite warnings.
|
|
275
|
+
if (!result.terminalAvailable) {
|
|
276
|
+
console.warn("[roamcode] ⚠ terminal sessions are DISABLED — tmux and/or node-pty is unavailable. Install tmux " +
|
|
277
|
+
"(macOS: brew install tmux; Debian/Ubuntu: apt install tmux) and ensure node-pty built, then restart.");
|
|
278
|
+
}
|
|
279
|
+
const url = await result.app.listen({ port: config.port, host: config.bindAddress });
|
|
280
|
+
// mcp-send wiring: now that listen() resolved the real port, give the terminal manager the LOOPBACK base
|
|
281
|
+
// URL (the spawned mcp-send.js POSTs back to 127.0.0.1, never the public bind), this deploy's token, and
|
|
282
|
+
// the resolved path to dist/mcp-send.js. Every terminal spawn then loads the send server so claude can
|
|
283
|
+
// deliver files/images to the terminal. The script path is resolved relative to THIS module so it works
|
|
284
|
+
// wherever the server is installed.
|
|
285
|
+
const { port: listenPort } = result.app.server.address();
|
|
286
|
+
const attachConfig = {
|
|
287
|
+
baseUrl: `http://127.0.0.1:${listenPort}`,
|
|
288
|
+
token: token ?? "",
|
|
289
|
+
mcpScriptPath: fileURLToPath(new URL("./mcp-send.js", import.meta.url)),
|
|
290
|
+
// Per-session 0600 mcp-config-<id>.json files are written into the data dir (mode 0700), keeping
|
|
291
|
+
// the access token out of every process's argv.
|
|
292
|
+
dataDir: config.dataDir,
|
|
293
|
+
};
|
|
294
|
+
// Terminal sessions → the terminal's claude gets send_image/send_file too.
|
|
295
|
+
result.terminalManager.setAttachConfig(attachConfig);
|
|
296
|
+
// Now that rehydrate (adopt survivors) + setAttachConfig (dataDir) have both run, delete leaked
|
|
297
|
+
// per-session mcp-config-<id>.json files (they carry the token) whose session no longer exists.
|
|
298
|
+
const swept = result.terminalManager.sweepStaleMcpConfigs();
|
|
299
|
+
if (swept > 0)
|
|
300
|
+
console.log(`swept ${swept} stale mcp-config file(s)`);
|
|
301
|
+
// LIVE STATUS MONITOR — re-derive every running session's working-vs-awaiting flag from its rendered tmux
|
|
302
|
+
// pane every ~2.5s (universal + hook-free; see TerminalManager.refreshActivity). This is what makes the
|
|
303
|
+
// session rail's statuses actually track reality, for OLD sessions too. capture-pane is READ-ONLY so it can
|
|
304
|
+
// never disturb a live session; a re-entrancy guard stops a slow sweep from stacking tmux spawns; unref'd so
|
|
305
|
+
// it never holds the process open.
|
|
306
|
+
let activityBusy = false;
|
|
307
|
+
const activityTimer = setInterval(() => {
|
|
308
|
+
if (activityBusy)
|
|
309
|
+
return;
|
|
310
|
+
activityBusy = true;
|
|
311
|
+
void result.terminalManager
|
|
312
|
+
.refreshActivity()
|
|
313
|
+
.catch(() => {
|
|
314
|
+
/* the monitor is best-effort — a sweep failure must never crash the server */
|
|
315
|
+
})
|
|
316
|
+
.finally(() => {
|
|
317
|
+
activityBusy = false;
|
|
318
|
+
});
|
|
319
|
+
}, 2500);
|
|
320
|
+
if (typeof activityTimer.unref === "function")
|
|
321
|
+
activityTimer.unref();
|
|
322
|
+
return { ...result, url, token, tokenGenerated: generated };
|
|
323
|
+
}
|
|
324
|
+
/** Default PWA location relative to @roamcode.ai/server/dist. This resolves to sibling @roamcode.ai/web/dist
|
|
325
|
+
* in both the workspace and npm's scoped node_modules layout. */
|
|
326
|
+
function defaultWebDir() {
|
|
327
|
+
// Keep this path math in sync with tsup's outDir; the release boot smoke catches packaging drift.
|
|
328
|
+
const here = fileURLToPath(new URL(".", import.meta.url));
|
|
329
|
+
return join(here, "..", "..", "web", "dist");
|
|
330
|
+
}
|
|
331
|
+
/** Install process-wide crash guards so a stray unhandled rejection or a listener-less EventEmitter
|
|
332
|
+
* `error` (e.g. a write-after-teardown on a dying claude child, or a detached updater spawn failure)
|
|
333
|
+
* LOGS instead of taking the whole server down — for an always-on self-hosted server, staying up beats
|
|
334
|
+
* crashing. Install ONCE at a process entry (never in startServer, which tests call repeatedly). */
|
|
335
|
+
export function installCrashGuards() {
|
|
336
|
+
process.on("unhandledRejection", (reason) => {
|
|
337
|
+
const msg = reason instanceof Error ? (reason.stack ?? reason.message) : String(reason);
|
|
338
|
+
console.error(`[roamcode] unhandled rejection (kept serving): ${msg}`);
|
|
339
|
+
});
|
|
340
|
+
process.on("uncaughtException", (err) => {
|
|
341
|
+
console.error(`[roamcode] uncaught exception (kept serving): ${err.stack ?? err.message}`);
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
// Run when executed directly (node dist/start.js), not when imported.
|
|
345
|
+
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
|
|
346
|
+
startServer()
|
|
347
|
+
.then(({ app, url, token, tokenGenerated }) => {
|
|
348
|
+
console.log(`roamcode server listening on ${url}`);
|
|
349
|
+
if (tokenGenerated && token) {
|
|
350
|
+
console.log(`\n Access token (generated, stored in the data dir):\n ${token}\n Open: ${url}/?token=${token}\n`);
|
|
351
|
+
}
|
|
352
|
+
else if (!token) {
|
|
353
|
+
console.log(` (NO_TOKEN tokenless loopback dev mode — no access token required)`);
|
|
354
|
+
}
|
|
355
|
+
// Graceful shutdown: app.close() fires the onClose hook, which stops every live session
|
|
356
|
+
// (and its child `claude`), so a deployment leaves no orphaned processes.
|
|
357
|
+
const shutdown = (signal) => {
|
|
358
|
+
console.log(`received ${signal}, shutting down`);
|
|
359
|
+
app
|
|
360
|
+
.close()
|
|
361
|
+
.then(() => process.exit(0))
|
|
362
|
+
.catch(() => process.exit(0));
|
|
363
|
+
};
|
|
364
|
+
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
|
365
|
+
process.on("SIGINT", () => shutdown("SIGINT"));
|
|
366
|
+
installCrashGuards();
|
|
367
|
+
})
|
|
368
|
+
.catch((err) => {
|
|
369
|
+
console.error(`roamcode server failed to start: ${err.message}`);
|
|
370
|
+
process.exit(1);
|
|
371
|
+
});
|
|
372
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import type { FastifyInstance } from "fastify";
|
|
2
|
+
/**
|
|
3
|
+
* Server-side mirror of the web SW's `apiNavigationDenylist` (packages/web/src/pwa/sw-exclusions.ts),
|
|
4
|
+
* EXTENDED with /health, /push and the /ws suffix. A request whose path matches one of these is a
|
|
5
|
+
* live API/WS/health/push route — the SPA navigation fallback must NOT serve index.html for it (it
|
|
6
|
+
* must hit the real handler), and the auth gate must NOT treat it as a public static asset.
|
|
7
|
+
*
|
|
8
|
+
* SYNC INVARIANT: this denylist (and `isPublicForRequest` below) decides which requests skip the
|
|
9
|
+
* token gate, while Fastify's find-my-way router decides which handler a request reaches. The two
|
|
10
|
+
* MUST agree on path normalization, or a request can look public to the gate yet reach a protected
|
|
11
|
+
* handler (an auth bypass). We gate on the DECODED path (`pathForGate`) to match the router's
|
|
12
|
+
* percent-decoding, and we reject encoded separators (`hasEncodedSep`). If Fastify is ever
|
|
13
|
+
* configured with `caseSensitive:false` or `ignoreDuplicateSlashes:true`, this gate must apply the
|
|
14
|
+
* SAME case/slash normalization first, or those options will silently reopen the bypass.
|
|
15
|
+
*/
|
|
16
|
+
export declare const API_PATH_DENYLIST: RegExp[];
|
|
17
|
+
/**
|
|
18
|
+
* The EXPLICIT public-shell allowlist — the only paths the auth gate lets a REGISTERED route serve
|
|
19
|
+
* without a token. It must cover exactly what an unauthenticated PWA boot needs (the login screen has to
|
|
20
|
+
* render before a token exists): the root shell, the Vite asset dir, and the top-level bundle files
|
|
21
|
+
* (index.html, sw.js, manifest.webmanifest, icons — matched by extension so a new icon the web build adds
|
|
22
|
+
* doesn't need a server change). Everything else — including any FUTURE route nobody remembered to gate —
|
|
23
|
+
* is token-gated by default (see the transport preHandler).
|
|
24
|
+
*
|
|
25
|
+
* SAFE because only static handlers live at these shapes: every API route is extensionless and no API
|
|
26
|
+
* route sits at `/` or under `/assets/`. Keep it that way — an API route named like a top-level static
|
|
27
|
+
* file (e.g. `/report.json`) would be silently public.
|
|
28
|
+
*/
|
|
29
|
+
export declare const SHELL_PATH_ALLOWLIST: RegExp[];
|
|
30
|
+
/** True for the static PWA shell paths above (the auth gate's explicit allowlist). */
|
|
31
|
+
export declare function isShellPath(path: string): boolean;
|
|
32
|
+
/**
|
|
33
|
+
* Normalize a raw request URL to the path the FASTIFY ROUTER will route, for the auth gate.
|
|
34
|
+
* find-my-way routes the percent-DECODED path, so we must gate on the decoded path too — otherwise
|
|
35
|
+
* `GET /%73essions` (`%73`=`s`) looks public to a raw-path gate but routes to `/sessions`.
|
|
36
|
+
* A malformed escape (decodeURIComponent throws) falls back to the raw path (still gated by the
|
|
37
|
+
* caller's encoded-separator check), never crashing the gate.
|
|
38
|
+
*/
|
|
39
|
+
export declare function pathForGate(rawUrl: string): string;
|
|
40
|
+
/**
|
|
41
|
+
* True if the RAW (pre-decode) path carries an encoded slash or backslash (`%2f`/`%2F`/`%5c`/`%5C`).
|
|
42
|
+
* find-my-way's handling of encoded slashes can desync from a single `decodeURIComponent`, and any
|
|
43
|
+
* request that needs an encoded separator to look public is inherently suspicious — so we treat such
|
|
44
|
+
* requests as NON-public (gated) regardless of what they decode to.
|
|
45
|
+
*/
|
|
46
|
+
export declare function hasEncodedSep(rawUrl: string): boolean;
|
|
47
|
+
/**
|
|
48
|
+
* The auth-boundary decision for a real request: a request is public (skips the token gate / may get
|
|
49
|
+
* the SPA shell) IFF it has no encoded separator AND its decoded path is a public static/shell path.
|
|
50
|
+
* Both the preHandler bypass (transport.ts) and the SPA `setNotFoundHandler` MUST use this, so the
|
|
51
|
+
* gate and the router agree on what is reachable.
|
|
52
|
+
*/
|
|
53
|
+
export declare function isPublicForRequest(rawUrl: string): boolean;
|
|
54
|
+
/**
|
|
55
|
+
* True for the PUBLIC static shell (HTML/JS/CSS/icons/manifest/sw + SPA navigations) — served WITHOUT
|
|
56
|
+
* a token so the login screen can load and THEN authenticate. A path is public iff it is on the shell
|
|
57
|
+
* allowlist, OR it is an extensionless NAVIGATION path (an SPA client route like `/login` gets
|
|
58
|
+
* index.html on a hard refresh) outside the reserved API namespace. (The served bundle carries no
|
|
59
|
+
* secret: the token lives only in the browser localStorage.)
|
|
60
|
+
*
|
|
61
|
+
* NOTE this predicate alone is NOT the auth boundary for routes: the transport preHandler is
|
|
62
|
+
* DEFAULT-DENY — it only honors the navigation half of this when the request matched NO registered
|
|
63
|
+
* route (fastify's `is404`), so a real handler can never hide behind an extensionless path. The
|
|
64
|
+
* navigation half here also drives the SPA fallback in {@link registerStatic}'s notFound handler.
|
|
65
|
+
*
|
|
66
|
+
* INVARIANT: a built shell asset must NEVER live at a path starting `/sessions`, `/fs`, `/ws`,
|
|
67
|
+
* `/health`, or `/push`. This holds for the Vite build (assets are emitted under `/assets/`, plus
|
|
68
|
+
* root `/index.html`, `/icon-*.svg`, `/manifest.webmanifest`, `/sw.js`) — none collide with the
|
|
69
|
+
* denylist. If a future asset were ever emitted under one of those prefixes, this default-deny would
|
|
70
|
+
* wrongly 401 a real asset to an unauthenticated shell load (the shell would fail to boot). Keep the
|
|
71
|
+
* Vite output prefixes clear of the denylist, or special-case the asset path here.
|
|
72
|
+
*/
|
|
73
|
+
export declare function isPublicPath(path: string): boolean;
|
|
74
|
+
/**
|
|
75
|
+
* True if the path looks like a request for a static FILE — it lives under `/assets/` or its last
|
|
76
|
+
* segment has an extension (e.g. `.js`/`.css`/`.png`/`.svg`). A file request that reaches the
|
|
77
|
+
* notFound handler means the file does NOT exist on disk, so it must 404 — it must NOT fall back to
|
|
78
|
+
* index.html. Serving the HTML shell for a missing `.js` makes the browser block the module script
|
|
79
|
+
* (MIME mismatch → blank page) and can POISON a service-worker precache (HTML cached as the JS
|
|
80
|
+
* entry). Only extensionless navigation paths (client routes like `/`, `/login`) get the SPA shell.
|
|
81
|
+
*
|
|
82
|
+
* Operational note: `@fastify/static` with `wildcard:false` globs the dist and registers one route
|
|
83
|
+
* per file AT STARTUP, so after rebuilding the web bundle (new content-hashed filenames) the server
|
|
84
|
+
* must be RESTARTED for the new assets to get routes — otherwise they reach this handler and (now)
|
|
85
|
+
* correctly 404 instead of silently serving the shell.
|
|
86
|
+
*/
|
|
87
|
+
export declare function looksLikeAssetRequest(path: string): boolean;
|
|
88
|
+
export interface RegisterStaticOptions {
|
|
89
|
+
/** Absolute path to the built PWA (packages/web/dist). */
|
|
90
|
+
webDir: string;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Serve the built PWA at `/` with an SPA fallback: a GET NAVIGATION (extensionless, public, non-API)
|
|
94
|
+
* that no static file matched returns index.html, so client routes (e.g. /login) work on a hard
|
|
95
|
+
* refresh. A request that looks like a missing FILE (`/assets/*`, or any `*.ext`) is served LIVE from
|
|
96
|
+
* disk when the file exists under webDir but @fastify/static's startup glob missed it (a build that
|
|
97
|
+
* landed after this server started — an OTA window / manual rebuild); otherwise it returns 404, never
|
|
98
|
+
* the shell — so a missing asset fails loudly instead of poisoning a browser/SW cache with HTML. An
|
|
99
|
+
* unknown /sessions/... still 404/401s from the real handlers / the gate.
|
|
100
|
+
*/
|
|
101
|
+
export declare function registerStatic(app: FastifyInstance, opts: RegisterStaticOptions): void;
|