@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.
Files changed (100) hide show
  1. package/LICENSE +21 -0
  2. package/dist/auth.d.ts +63 -0
  3. package/dist/auth.js +133 -0
  4. package/dist/claude-auth-service.d.ts +76 -0
  5. package/dist/claude-auth-service.js +217 -0
  6. package/dist/claude-latest-service.d.ts +34 -0
  7. package/dist/claude-latest-service.js +61 -0
  8. package/dist/config.d.ts +78 -0
  9. package/dist/config.js +59 -0
  10. package/dist/data-dir.d.ts +42 -0
  11. package/dist/data-dir.js +70 -0
  12. package/dist/diag.d.ts +43 -0
  13. package/dist/diag.js +83 -0
  14. package/dist/fs-service.d.ts +90 -0
  15. package/dist/fs-service.js +290 -0
  16. package/dist/index.d.ts +82 -0
  17. package/dist/index.js +44 -0
  18. package/dist/managed-runtime.d.ts +51 -0
  19. package/dist/managed-runtime.js +411 -0
  20. package/dist/managed-update-helper.d.ts +2 -0
  21. package/dist/managed-update-helper.js +34 -0
  22. package/dist/mcp-send.d.ts +33 -0
  23. package/dist/mcp-send.js +107 -0
  24. package/dist/origin-check.d.ts +37 -0
  25. package/dist/origin-check.js +101 -0
  26. package/dist/pane-status.d.ts +61 -0
  27. package/dist/pane-status.js +145 -0
  28. package/dist/providers/claude-metadata-service.d.ts +58 -0
  29. package/dist/providers/claude-metadata-service.js +352 -0
  30. package/dist/providers/claude-provider.d.ts +11 -0
  31. package/dist/providers/claude-provider.js +166 -0
  32. package/dist/providers/codex-activity.d.ts +21 -0
  33. package/dist/providers/codex-activity.js +122 -0
  34. package/dist/providers/codex-app-server-client.d.ts +90 -0
  35. package/dist/providers/codex-app-server-client.js +485 -0
  36. package/dist/providers/codex-latest-service.d.ts +50 -0
  37. package/dist/providers/codex-latest-service.js +174 -0
  38. package/dist/providers/codex-metadata-service.d.ts +161 -0
  39. package/dist/providers/codex-metadata-service.js +686 -0
  40. package/dist/providers/codex-profile-client.d.ts +16 -0
  41. package/dist/providers/codex-profile-client.js +52 -0
  42. package/dist/providers/codex-profile-security.d.ts +23 -0
  43. package/dist/providers/codex-profile-security.js +161 -0
  44. package/dist/providers/codex-provider.d.ts +15 -0
  45. package/dist/providers/codex-provider.js +174 -0
  46. package/dist/providers/codex-thread-coordinator.d.ts +18 -0
  47. package/dist/providers/codex-thread-coordinator.js +93 -0
  48. package/dist/providers/codex-thread-persistence.d.ts +9 -0
  49. package/dist/providers/codex-thread-persistence.js +45 -0
  50. package/dist/providers/codex-thread-resolver.d.ts +59 -0
  51. package/dist/providers/codex-thread-resolver.js +322 -0
  52. package/dist/providers/options.d.ts +7 -0
  53. package/dist/providers/options.js +155 -0
  54. package/dist/providers/provider-artifacts.d.ts +3 -0
  55. package/dist/providers/provider-artifacts.js +30 -0
  56. package/dist/providers/registry.d.ts +7 -0
  57. package/dist/providers/registry.js +23 -0
  58. package/dist/providers/types.d.ts +95 -0
  59. package/dist/providers/types.js +8 -0
  60. package/dist/push-dispatch.d.ts +81 -0
  61. package/dist/push-dispatch.js +100 -0
  62. package/dist/push-store.d.ts +25 -0
  63. package/dist/push-store.js +79 -0
  64. package/dist/rate-limit.d.ts +52 -0
  65. package/dist/rate-limit.js +72 -0
  66. package/dist/server-config.d.ts +60 -0
  67. package/dist/server-config.js +77 -0
  68. package/dist/service-install.d.ts +60 -0
  69. package/dist/service-install.js +221 -0
  70. package/dist/session-defaults.d.ts +26 -0
  71. package/dist/session-defaults.js +60 -0
  72. package/dist/session-store.d.ts +81 -0
  73. package/dist/session-store.js +654 -0
  74. package/dist/start.d.ts +31 -0
  75. package/dist/start.js +372 -0
  76. package/dist/static-routes.d.ts +101 -0
  77. package/dist/static-routes.js +188 -0
  78. package/dist/terminal-capability.d.ts +5 -0
  79. package/dist/terminal-capability.js +27 -0
  80. package/dist/terminal-manager.d.ts +224 -0
  81. package/dist/terminal-manager.js +917 -0
  82. package/dist/terminal-process.d.ts +85 -0
  83. package/dist/terminal-process.js +238 -0
  84. package/dist/terminal-shared.d.ts +36 -0
  85. package/dist/terminal-shared.js +43 -0
  86. package/dist/tmux-list.d.ts +11 -0
  87. package/dist/tmux-list.js +39 -0
  88. package/dist/transport.d.ts +123 -0
  89. package/dist/transport.js +1559 -0
  90. package/dist/updater.d.ts +161 -0
  91. package/dist/updater.js +451 -0
  92. package/dist/usage-service.d.ts +118 -0
  93. package/dist/usage-service.js +173 -0
  94. package/dist/vapid.d.ts +17 -0
  95. package/dist/vapid.js +31 -0
  96. package/dist/web-push-send.d.ts +20 -0
  97. package/dist/web-push-send.js +21 -0
  98. package/dist/ws-ticket.d.ts +47 -0
  99. package/dist/ws-ticket.js +62 -0
  100. package/package.json +55 -0
@@ -0,0 +1,411 @@
1
+ import { spawn } from "node:child_process";
2
+ import { randomUUID } from "node:crypto";
3
+ import { chmodSync, existsSync, lstatSync, mkdirSync, openSync, closeSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, symlinkSync, unlinkSync, writeFileSync, } from "node:fs";
4
+ import { homedir, tmpdir } from "node:os";
5
+ import { basename, dirname, join, relative, resolve } from "node:path";
6
+ import { migrateServiceToLauncher, readServiceRecord, restartService } from "./service-install.js";
7
+ const VERSION_RE = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
8
+ const LOCK_STALE_MS = 30 * 60_000;
9
+ export function resolveInstallRoot(env = process.env, home = homedir()) {
10
+ return env.ROAMCODE_INSTALL_ROOT || join(home, ".local", "share", "roamcode");
11
+ }
12
+ export function managedPaths(root) {
13
+ return {
14
+ root,
15
+ releases: join(root, "releases"),
16
+ staging: join(root, "staging"),
17
+ current: join(root, "current"),
18
+ previous: join(root, "previous"),
19
+ bin: join(root, "bin"),
20
+ launcher: join(root, "bin", "roamcode"),
21
+ lock: join(root, "update.lock"),
22
+ };
23
+ }
24
+ export function isStableVersion(value) {
25
+ return VERSION_RE.test(value);
26
+ }
27
+ export function compareVersions(a, b) {
28
+ if (!isStableVersion(a) || !isStableVersion(b))
29
+ throw new Error(`invalid stable version: ${a} / ${b}`);
30
+ const av = a.split(".").map(Number);
31
+ const bv = b.split(".").map(Number);
32
+ for (let i = 0; i < 3; i += 1) {
33
+ if (av[i] !== bv[i])
34
+ return av[i] < bv[i] ? -1 : 1;
35
+ }
36
+ return 0;
37
+ }
38
+ function atomicWrite(path, value, mode = 0o600) {
39
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
40
+ const temp = `${path}.${process.pid}.${randomUUID()}.tmp`;
41
+ writeFileSync(temp, value, { mode });
42
+ chmodSync(temp, mode);
43
+ renameSync(temp, path);
44
+ }
45
+ export function renderManagedLauncher(root, nodePath) {
46
+ const q = (value) => `'${value.replace(/'/g, `'\\''`)}'`;
47
+ return [
48
+ "#!/bin/sh",
49
+ "set -eu",
50
+ `ROOT=${q(root)}`,
51
+ `NODE=${q(nodePath)}`,
52
+ 'if [ ! -x "$NODE" ]; then NODE="$(command -v node || true)"; fi',
53
+ 'if [ -z "$NODE" ]; then echo "Node.js >= 24 is required" >&2; exit 1; fi',
54
+ 'ENTRY="$ROOT/current/node_modules/roamcode/dist/index.js"',
55
+ 'if [ ! -f "$ENTRY" ]; then',
56
+ ' echo "roamcode managed runtime is missing; run: npx roamcode@latest install" >&2',
57
+ " exit 1",
58
+ "fi",
59
+ 'export ROAMCODE_INSTALL_ROOT="$ROOT"',
60
+ 'export ROAMCODE_MANAGED_EXEC="1"',
61
+ 'exec "$NODE" "$ENTRY" "$@"',
62
+ "",
63
+ ].join("\n");
64
+ }
65
+ export function writeManagedLauncher(root, nodePath = process.execPath) {
66
+ const paths = managedPaths(root);
67
+ mkdirSync(paths.bin, { recursive: true, mode: 0o700 });
68
+ atomicWrite(paths.launcher, renderManagedLauncher(root, nodePath), 0o700);
69
+ return paths.launcher;
70
+ }
71
+ function versionFromReleaseDir(dir) {
72
+ try {
73
+ const manifest = JSON.parse(readFileSync(join(dir, "release.json"), "utf8"));
74
+ if (typeof manifest.version === "string" && isStableVersion(manifest.version))
75
+ return manifest.version;
76
+ }
77
+ catch {
78
+ // Older managed installs may not have release.json; fall back to the npm package manifest.
79
+ }
80
+ try {
81
+ const pkg = JSON.parse(readFileSync(join(dir, "node_modules", "roamcode", "package.json"), "utf8"));
82
+ return typeof pkg.version === "string" && isStableVersion(pkg.version) ? pkg.version : undefined;
83
+ }
84
+ catch {
85
+ return undefined;
86
+ }
87
+ }
88
+ function integrityFromReleaseDir(dir) {
89
+ try {
90
+ const manifest = JSON.parse(readFileSync(join(dir, "release.json"), "utf8"));
91
+ return typeof manifest.integrity === "string" ? manifest.integrity : undefined;
92
+ }
93
+ catch {
94
+ return undefined;
95
+ }
96
+ }
97
+ export function readActiveVersion(root) {
98
+ const current = managedPaths(root).current;
99
+ try {
100
+ return versionFromReleaseDir(realpathSync(current));
101
+ }
102
+ catch {
103
+ return undefined;
104
+ }
105
+ }
106
+ export function readPreviousVersion(root) {
107
+ try {
108
+ return versionFromReleaseDir(realpathSync(managedPaths(root).previous));
109
+ }
110
+ catch {
111
+ return undefined;
112
+ }
113
+ }
114
+ function acquireLock(path, now) {
115
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
116
+ try {
117
+ const fd = openSync(path, "wx", 0o600);
118
+ writeFileSync(fd, JSON.stringify({ pid: process.pid, at: now }) + "\n");
119
+ closeSync(fd);
120
+ }
121
+ catch {
122
+ try {
123
+ if (now - lstatSync(path).mtimeMs > LOCK_STALE_MS) {
124
+ unlinkSync(path);
125
+ return acquireLock(path, now);
126
+ }
127
+ }
128
+ catch {
129
+ // The competing process may have removed it; the retry below will either acquire or report busy.
130
+ try {
131
+ return acquireLock(path, now);
132
+ }
133
+ catch {
134
+ // handled by the stable error below
135
+ }
136
+ }
137
+ throw new Error("an update is already in progress");
138
+ }
139
+ return () => {
140
+ try {
141
+ unlinkSync(path);
142
+ }
143
+ catch {
144
+ // best effort
145
+ }
146
+ };
147
+ }
148
+ async function runLogged(command, args, opts) {
149
+ await new Promise((resolvePromise, reject) => {
150
+ const child = spawn(command, args, {
151
+ cwd: opts.cwd,
152
+ env: opts.env ?? process.env,
153
+ stdio: ["ignore", "pipe", "pipe"],
154
+ });
155
+ child.stdout?.on("data", (chunk) => opts.log(chunk.toString()));
156
+ child.stderr?.on("data", (chunk) => opts.log(chunk.toString()));
157
+ child.on("error", reject);
158
+ child.on("close", (code) => code === 0 ? resolvePromise() : reject(new Error(`${basename(command)} exited ${code}`)));
159
+ });
160
+ }
161
+ function npmInvocation(npmCommand, args, nodePath) {
162
+ if (npmCommand.endsWith(".js") || npmCommand.endsWith(".cjs"))
163
+ return { command: nodePath, args: [npmCommand, ...args] };
164
+ return { command: npmCommand, args };
165
+ }
166
+ async function npmIntegrity(npmCommand, packageName, version, nodePath, log) {
167
+ let output = "";
168
+ const invocation = npmInvocation(npmCommand, ["view", `${packageName}@${version}`, "dist.integrity", "--json"], nodePath);
169
+ await new Promise((resolvePromise, reject) => {
170
+ const child = spawn(invocation.command, invocation.args, { stdio: ["ignore", "pipe", "pipe"] });
171
+ child.stdout?.on("data", (chunk) => (output += chunk.toString()));
172
+ child.stderr?.on("data", (chunk) => log(chunk.toString()));
173
+ child.on("error", reject);
174
+ child.on("close", (code) => (code === 0 ? resolvePromise() : reject(new Error(`npm view exited ${code}`))));
175
+ });
176
+ const parsed = JSON.parse(output);
177
+ if (typeof parsed !== "string" || !parsed.startsWith("sha512-"))
178
+ throw new Error("npm returned no package integrity");
179
+ return parsed;
180
+ }
181
+ async function smokeServer(serverEntry, dataDir, nodePath, log) {
182
+ const smokeDir = join(tmpdir(), `roamcode-smoke-${process.pid}-${randomUUID()}`);
183
+ mkdirSync(smokeDir, { recursive: true, mode: 0o700 });
184
+ let output = "";
185
+ const child = spawn(nodePath, [serverEntry], {
186
+ env: {
187
+ ...process.env,
188
+ PORT: "0",
189
+ BIND_ADDRESS: "127.0.0.1",
190
+ ACCESS_TOKEN: `rc-smoke-${randomUUID()}`,
191
+ ROAMCODE_DATA_DIR: smokeDir,
192
+ RC_TMUX_SOCKET: `rc-smoke-${process.pid}`,
193
+ ROAMCODE_INSTALL_ROOT: "",
194
+ },
195
+ stdio: ["ignore", "pipe", "pipe"],
196
+ });
197
+ const collect = (chunk) => {
198
+ const text = chunk.toString();
199
+ output += text;
200
+ log(text);
201
+ };
202
+ child.stdout?.on("data", collect);
203
+ child.stderr?.on("data", collect);
204
+ try {
205
+ const url = await new Promise((resolveUrl, reject) => {
206
+ const deadline = setTimeout(() => reject(new Error("new release did not start within 25s")), 25_000);
207
+ const poll = setInterval(() => {
208
+ const match = output.match(/listening on (http:\/\/127\.0\.0\.1:\d+)/);
209
+ if (match?.[1]) {
210
+ clearInterval(poll);
211
+ clearTimeout(deadline);
212
+ resolveUrl(match[1]);
213
+ }
214
+ }, 100);
215
+ child.once("exit", (code) => {
216
+ clearInterval(poll);
217
+ clearTimeout(deadline);
218
+ reject(new Error(`new release exited before health check (${code})`));
219
+ });
220
+ });
221
+ const healthy = await fetch(`${url}/health`, { signal: AbortSignal.timeout(5_000) });
222
+ if (!healthy.ok)
223
+ throw new Error(`new release health check returned ${healthy.status}`);
224
+ }
225
+ finally {
226
+ child.kill("SIGTERM");
227
+ rmSync(smokeDir, { recursive: true, force: true });
228
+ // dataDir is intentionally part of the signature: callers cannot accidentally smoke with production data.
229
+ void dataDir;
230
+ }
231
+ }
232
+ function replaceSymlink(link, target) {
233
+ const temp = `${link}.${process.pid}.${randomUUID()}.new`;
234
+ try {
235
+ unlinkSync(temp);
236
+ }
237
+ catch {
238
+ // absent
239
+ }
240
+ // Canonicalize both sides before computing a relative link. On macOS `/var` resolves to
241
+ // `/private/var`; mixing one spelling from mkdtemp with the other from realpath creates a broken link.
242
+ const parent = realpathSync(dirname(link));
243
+ const canonicalTarget = realpathSync(target);
244
+ symlinkSync(relative(parent, canonicalTarget), temp, "dir");
245
+ renameSync(temp, link);
246
+ }
247
+ function trimLog(log) {
248
+ return log.split("\n").slice(-20).join("\n").slice(-8_000);
249
+ }
250
+ export async function installManagedRelease(opts) {
251
+ if (!isStableVersion(opts.version))
252
+ throw new Error(`invalid release version: ${opts.version}`);
253
+ const now = opts.now ?? Date.now;
254
+ const operationId = opts.operationId ?? randomUUID();
255
+ const nodePath = opts.nodePath ?? process.execPath;
256
+ const npmCommand = opts.npmCommand ?? process.env.npm_execpath ?? "npm";
257
+ const expectedIntegrities = opts.expectedIntegrities ?? (opts.expectedIntegrity ? { roamcode: opts.expectedIntegrity } : {});
258
+ const rootIntegrity = expectedIntegrities.roamcode;
259
+ const paths = managedPaths(opts.installRoot);
260
+ mkdirSync(paths.releases, { recursive: true, mode: 0o700 });
261
+ mkdirSync(paths.staging, { recursive: true, mode: 0o700 });
262
+ const release = join(paths.releases, opts.version);
263
+ const stage = join(paths.staging, `${opts.version}-${process.pid}-${randomUUID()}`);
264
+ let logText = "";
265
+ const log = (line) => {
266
+ logText += line.endsWith("\n") ? line : `${line}\n`;
267
+ mkdirSync(opts.dataDir, { recursive: true, mode: 0o700 });
268
+ writeFileSync(join(opts.dataDir, "update.log"), line.endsWith("\n") ? line : `${line}\n`, { flag: "a" });
269
+ };
270
+ const fromVersion = readActiveVersion(opts.installRoot);
271
+ const status = (state, phase, error) => {
272
+ opts.onStatus?.({
273
+ operationId,
274
+ state,
275
+ phase,
276
+ target: opts.version,
277
+ ...(fromVersion ? { fromVersion } : {}),
278
+ ...(error ? { error } : {}),
279
+ log: trimLog(logText),
280
+ updatedAt: now(),
281
+ });
282
+ };
283
+ const releaseLock = acquireLock(paths.lock, now());
284
+ let activated = false;
285
+ try {
286
+ status("starting", "preparing");
287
+ const reusableRelease = existsSync(release) &&
288
+ versionFromReleaseDir(release) === opts.version &&
289
+ (!rootIntegrity || integrityFromReleaseDir(release) === rootIntegrity);
290
+ if (!reusableRelease) {
291
+ if (Object.keys(expectedIntegrities).length > 0) {
292
+ status("downloading", "checking package integrity");
293
+ for (const [packageName, expected] of Object.entries(expectedIntegrities)) {
294
+ const actual = await npmIntegrity(npmCommand, packageName, opts.version, nodePath, log);
295
+ if (actual !== expected)
296
+ throw new Error(`GitHub release and npm integrity do not match for ${packageName}`);
297
+ }
298
+ }
299
+ status("installing", "installing package");
300
+ mkdirSync(stage, { recursive: true, mode: 0o700 });
301
+ const invocation = npmInvocation(npmCommand, [
302
+ "install",
303
+ "--prefix",
304
+ stage,
305
+ "--omit=dev",
306
+ "--no-audit",
307
+ "--no-fund",
308
+ "--package-lock=false",
309
+ `roamcode@${opts.version}`,
310
+ ], nodePath);
311
+ await runLogged(invocation.command, invocation.args, { log });
312
+ const installed = versionFromReleaseDir(stage);
313
+ if (installed !== opts.version)
314
+ throw new Error(`installed roamcode ${installed ?? "unknown"}, expected ${opts.version}`);
315
+ const serverEntry = join(stage, "node_modules", "@roamcode.ai", "server", "dist", "start.js");
316
+ if (!existsSync(serverEntry))
317
+ throw new Error("published package is missing @roamcode.ai/server/dist/start.js");
318
+ status("verifying", "boot smoke");
319
+ await smokeServer(serverEntry, opts.dataDir, nodePath, log);
320
+ atomicWrite(join(stage, "release.json"), JSON.stringify({
321
+ version: opts.version,
322
+ installedAt: now(),
323
+ package: `roamcode@${opts.version}`,
324
+ ...(rootIntegrity ? { integrity: rootIntegrity } : {}),
325
+ }, null, 2) + "\n");
326
+ if (existsSync(release))
327
+ rmSync(release, { recursive: true, force: true });
328
+ renameSync(stage, release);
329
+ }
330
+ else {
331
+ const serverEntry = join(release, "node_modules", "@roamcode.ai", "server", "dist", "start.js");
332
+ if (!existsSync(serverEntry))
333
+ throw new Error(`managed release ${opts.version} is incomplete`);
334
+ status("verifying", "verifying installed release");
335
+ await smokeServer(serverEntry, opts.dataDir, nodePath, log);
336
+ }
337
+ status("activating", "activating release");
338
+ let oldTarget;
339
+ try {
340
+ oldTarget = realpathSync(paths.current);
341
+ }
342
+ catch {
343
+ oldTarget = undefined;
344
+ }
345
+ if (oldTarget && resolve(oldTarget) !== resolve(release))
346
+ replaceSymlink(paths.previous, oldTarget);
347
+ replaceSymlink(paths.current, release);
348
+ activated = true;
349
+ const launcherPath = writeManagedLauncher(opts.installRoot, nodePath);
350
+ const existingService = readServiceRecord(opts.dataDir);
351
+ const record = existingService
352
+ ? migrateServiceToLauncher({
353
+ dataDir: opts.dataDir,
354
+ installRoot: opts.installRoot,
355
+ launcherPath,
356
+ nodePath,
357
+ })
358
+ : undefined;
359
+ if (opts.restart && record) {
360
+ status("restarting", "restarting service");
361
+ const restarted = restartService(record);
362
+ if (!restarted.ok)
363
+ throw new Error(restarted.error ?? "service restart failed");
364
+ }
365
+ status("done", "done");
366
+ // Keep only the active release and its rollback target. A failed/staged release is cleaned in finally.
367
+ const keep = new Set([opts.version, fromVersion].filter((value) => !!value));
368
+ for (const name of readdirSync(paths.releases)) {
369
+ if (isStableVersion(name) && !keep.has(name))
370
+ rmSync(join(paths.releases, name), { recursive: true, force: true });
371
+ }
372
+ return {
373
+ version: opts.version,
374
+ ...(fromVersion ? { previousVersion: fromVersion } : {}),
375
+ releaseDir: release,
376
+ launcherPath,
377
+ };
378
+ }
379
+ catch (error) {
380
+ const message = error instanceof Error ? error.message : String(error);
381
+ log(`FAILED: ${message}`);
382
+ // If activation happened but restart failed, restore the previous pointer when available.
383
+ if (activated) {
384
+ try {
385
+ const previous = realpathSync(paths.previous);
386
+ replaceSymlink(paths.current, previous);
387
+ }
388
+ catch {
389
+ // no previous managed release (first migration); the new release already passed boot-smoke
390
+ }
391
+ }
392
+ status("failed", "failed", message);
393
+ throw error;
394
+ }
395
+ finally {
396
+ rmSync(stage, { recursive: true, force: true });
397
+ releaseLock();
398
+ }
399
+ }
400
+ export function writeManagedStatus(dataDir, status) {
401
+ atomicWrite(join(dataDir, "update-status.json"), JSON.stringify(status, null, 2) + "\n", 0o600);
402
+ }
403
+ export function readManagedStatus(dataDir) {
404
+ try {
405
+ const parsed = JSON.parse(readFileSync(join(dataDir, "update-status.json"), "utf8"));
406
+ return parsed && typeof parsed.operationId === "string" && typeof parsed.state === "string" ? parsed : undefined;
407
+ }
408
+ catch {
409
+ return undefined;
410
+ }
411
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,34 @@
1
+ #!/usr/bin/env node
2
+ import { readFileSync, unlinkSync } from "node:fs";
3
+ import { installManagedRelease, writeManagedStatus } from "./managed-runtime.js";
4
+ async function main() {
5
+ const configPath = process.argv[2];
6
+ if (!configPath)
7
+ throw new Error("managed updater config path is required");
8
+ const config = JSON.parse(readFileSync(configPath, "utf8"));
9
+ try {
10
+ await installManagedRelease({
11
+ version: config.version,
12
+ installRoot: config.installRoot,
13
+ dataDir: config.dataDir,
14
+ operationId: config.operationId,
15
+ expectedIntegrity: config.expectedIntegrity,
16
+ expectedIntegrities: config.expectedIntegrities,
17
+ nodePath: config.nodePath,
18
+ restart: config.restart,
19
+ onStatus: (status) => writeManagedStatus(config.dataDir, status),
20
+ });
21
+ }
22
+ finally {
23
+ try {
24
+ unlinkSync(configPath);
25
+ }
26
+ catch {
27
+ // best effort; config contains no secret, but should not accumulate
28
+ }
29
+ }
30
+ }
31
+ void main().catch((error) => {
32
+ process.stderr.write(`roamcode updater failed: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}\n`);
33
+ process.exitCode = 1;
34
+ });
@@ -0,0 +1,33 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ export interface McpEnv {
3
+ RC_BASE_URL?: string;
4
+ RC_SESSION_ID?: string;
5
+ RC_TOKEN?: string;
6
+ RC_TOKEN_FILE?: string;
7
+ }
8
+ export interface DeliverArgs {
9
+ path: string;
10
+ caption?: string;
11
+ kind: "image" | "file";
12
+ }
13
+ /**
14
+ * A minimal MCP tool-result shape (content blocks + optional isError), enough for our two tools. The
15
+ * index signature keeps it assignable to the SDK's `Result` (`{ [x: string]: unknown }`) tool-callback
16
+ * return type without casting at the call sites.
17
+ */
18
+ export interface ToolResult {
19
+ [key: string]: unknown;
20
+ content: {
21
+ type: "text";
22
+ text: string;
23
+ }[];
24
+ isError?: boolean;
25
+ }
26
+ /**
27
+ * Pure, unit-testable core: POST the attachment to roamcode and map the response to a tool-result.
28
+ * NEVER throws — a bad path, a down server, or a network error all return an `isError` tool-result so
29
+ * Claude learns it failed and can tell the user. `fetchImpl` is injectable for tests (defaults to fetch).
30
+ */
31
+ export declare function deliver(env: McpEnv, args: DeliverArgs, fetchImpl?: typeof fetch): Promise<ToolResult>;
32
+ /** Build the MCP server with the two send tools wired to `deliver`. Reads env lazily per call. */
33
+ export declare function createMcpSendServer(env?: McpEnv): McpServer;
@@ -0,0 +1,107 @@
1
+ /**
2
+ * mcp-send — a stdio MCP server that lets Claude proactively SEND a file or image to the user's chat.
3
+ *
4
+ * roamcode spawns this as `claude`'s MCP subprocess (via --mcp-config). It exposes two tools
5
+ * (send_image / send_file); on a call it POSTs the file PATH to roamcode's
6
+ * `POST /sessions/:id/attach`, which validates the path (fsRoot+realpath) and pushes an `attachment`
7
+ * frame over the session's WebSocket so the web renders it (image inline, file as a download).
8
+ *
9
+ * The connection params arrive via env, with Claude supplying RC_TOKEN directly and Codex supplying a
10
+ * provider-owned RC_TOKEN_FILE. No ANTHROPIC_API_KEY, no @anthropic-ai dependency — only the MCP SDK + zod.
11
+ */
12
+ import { lstatSync, readFileSync } from "node:fs";
13
+ import { basename, isAbsolute } from "node:path";
14
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
15
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
16
+ import { z } from "zod";
17
+ function readTokenFile(path) {
18
+ try {
19
+ if (!isAbsolute(path))
20
+ return undefined;
21
+ const info = lstatSync(path);
22
+ if (!info.isFile() || info.isSymbolicLink() || (info.mode & 0o077) !== 0 || info.size < 1 || info.size > 4096) {
23
+ return undefined;
24
+ }
25
+ if (typeof process.getuid === "function" && info.uid !== process.getuid())
26
+ return undefined;
27
+ const token = readFileSync(path, "utf8");
28
+ return token.length <= 4096 && !/[\p{Cc}\p{Zl}\p{Zp}]/u.test(token) ? token : undefined;
29
+ }
30
+ catch {
31
+ return undefined;
32
+ }
33
+ }
34
+ function textResult(text, isError = false) {
35
+ return isError ? { content: [{ type: "text", text }], isError: true } : { content: [{ type: "text", text }] };
36
+ }
37
+ /**
38
+ * Pure, unit-testable core: POST the attachment to roamcode and map the response to a tool-result.
39
+ * NEVER throws — a bad path, a down server, or a network error all return an `isError` tool-result so
40
+ * Claude learns it failed and can tell the user. `fetchImpl` is injectable for tests (defaults to fetch).
41
+ */
42
+ export async function deliver(env, args, fetchImpl = fetch) {
43
+ const { RC_BASE_URL, RC_SESSION_ID } = env;
44
+ const token = env.RC_TOKEN || (env.RC_TOKEN_FILE ? readTokenFile(env.RC_TOKEN_FILE) : undefined);
45
+ if (!RC_BASE_URL || !RC_SESSION_ID || !token) {
46
+ return textResult("Attachment delivery is not configured (RC_BASE_URL / RC_SESSION_ID / RC_TOKEN missing).", true);
47
+ }
48
+ const url = `${RC_BASE_URL}/sessions/${RC_SESSION_ID}/attach`;
49
+ let res;
50
+ try {
51
+ res = await fetchImpl(url, {
52
+ method: "POST",
53
+ headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
54
+ body: JSON.stringify({ path: args.path, caption: args.caption, kind: args.kind }),
55
+ });
56
+ }
57
+ catch (err) {
58
+ return textResult(`Could not reach roamcode to send the file: ${err.message}`, true);
59
+ }
60
+ if (!res.ok) {
61
+ // Surface the server's error body (e.g. "path is outside the allowed root") so Claude can explain it.
62
+ let detail = `HTTP ${res.status}`;
63
+ try {
64
+ const body = (await res.json());
65
+ if (typeof body.error === "string")
66
+ detail = body.error;
67
+ }
68
+ catch {
69
+ // non-JSON error body — keep the status code detail
70
+ }
71
+ return textResult(`Failed to send ${basename(args.path)}: ${detail}`, true);
72
+ }
73
+ return textResult(`Sent ${basename(args.path)} to the user.`);
74
+ }
75
+ const SEND_PARAMS = {
76
+ path: z.string().describe("Absolute path to the file to send."),
77
+ caption: z.string().optional().describe("Optional caption shown with the file in the chat."),
78
+ };
79
+ /** Build the MCP server with the two send tools wired to `deliver`. Reads env lazily per call. */
80
+ export function createMcpSendServer(env = process.env) {
81
+ const server = new McpServer({ name: "roamcode-send", version: "0.0.0" });
82
+ server.registerTool("send_image", {
83
+ description: "Send an IMAGE to the user's chat so they can see it inline. Use when the user asks you to " +
84
+ "show/send an image, or to deliver a generated image. `path` must be an absolute path to the file.",
85
+ inputSchema: SEND_PARAMS,
86
+ }, async ({ path, caption }) => deliver(env, { path, caption, kind: "image" }));
87
+ server.registerTool("send_file", {
88
+ description: "Send a FILE to the user's chat as a download. Use when the user asks you to send/deliver a " +
89
+ "file. `path` must be an absolute path.",
90
+ inputSchema: SEND_PARAMS,
91
+ }, async ({ path, caption }) => deliver(env, { path, caption, kind: "file" }));
92
+ return server;
93
+ }
94
+ /** Run the stdio server when executed directly (node dist/mcp-send.js). */
95
+ async function main() {
96
+ const server = createMcpSendServer();
97
+ const transport = new StdioServerTransport();
98
+ await server.connect(transport);
99
+ }
100
+ // Only run when invoked as the entry script, not when imported by a test.
101
+ if (process.argv[1] && import.meta.url === new URL(`file://${process.argv[1]}`).href) {
102
+ main().catch((err) => {
103
+ // A stdio MCP server must keep stdout clean (it is the JSON-RPC channel) — log to stderr.
104
+ process.stderr.write(`mcp-send failed to start: ${err.message}\n`);
105
+ process.exit(1);
106
+ });
107
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Origin / CSWSH (cross-site WebSocket hijacking) guard for the WS upgrade and state-changing HTTP.
3
+ *
4
+ * The token is the only auth boundary, and it can leak into a URL (`?token=` connect link, push deep
5
+ * link, browser history). The WS handshake (and any browser-driven HTTP) carries an `Origin` header the
6
+ * page CANNOT forge — so a malicious cross-origin page that somehow holds the token still can't make the
7
+ * BROWSER attach to our WS / drive our API, because the browser stamps its OWN origin on the request and
8
+ * we reject it. Without this check, a leaked-in-URL token is enough for a foreign page to puppet the host.
9
+ *
10
+ * SAFE DEFAULT (must never reject the real app): a request is allowed when
11
+ * - the `Origin` header is ABSENT (native/non-browser clients, same-origin navigations + most
12
+ * same-origin GETs omit it — the PWA's own fetches are same-origin), OR
13
+ * - the Origin's host:port equals the request `Host` (same-origin — the PWA always is), OR
14
+ * - the Origin matches the configured public URL (ROAMCODE_PUBLIC_URL), OR
15
+ * - the Origin is a loopback/localhost origin (local dev), OR
16
+ * - the Origin is in the explicit ROAMCODE_ALLOWED_ORIGINS allow-list.
17
+ * Only a PRESENT, cross-origin, non-allow-listed Origin is rejected (403).
18
+ */
19
+ /** Normalize a URL/origin string to a comparable `scheme://host[:port]` origin (lowercased). Returns
20
+ * undefined for anything that isn't a parseable absolute URL (so it can never match by accident). */
21
+ export declare function normalizeOrigin(value: string | undefined): string | undefined;
22
+ export interface OriginCheckOptions {
23
+ /** The configured public-facing origin (from ROAMCODE_PUBLIC_URL). May be a full URL. */
24
+ publicUrl?: string;
25
+ /** Extra allow-listed origins (ROAMCODE_ALLOWED_ORIGINS, comma-separated → array). */
26
+ allowedOrigins?: string[];
27
+ }
28
+ /**
29
+ * Decide whether a request's Origin is allowed. PURE / I/O-free so it is unit-testable; the transport
30
+ * preHandler passes `origin` (the header) and `host` (the request Host header).
31
+ *
32
+ * @param origin the request's `Origin` header (may be undefined)
33
+ * @param host the request's `Host` header (e.g. `remotecode.example.com` or `127.0.0.1:4280`)
34
+ */
35
+ export declare function isOriginAllowed(origin: string | undefined, host: string | undefined, opts?: OriginCheckOptions): boolean;
36
+ /** Parse a comma-separated ROAMCODE_ALLOWED_ORIGINS value into a trimmed, non-empty list. */
37
+ export declare function parseAllowedOrigins(raw: string | undefined): string[];