viewable-one 0.0.0-stage → 0.12.2
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/CHANGELOG.md +1483 -0
- package/LICENSE +21 -0
- package/README.md +738 -2
- package/SPAWN.md +2108 -0
- package/glytch.mjs +517 -0
- package/lib/acp.mjs +1018 -0
- package/lib/admission.mjs +105 -0
- package/lib/agent-selection.mjs +124 -0
- package/lib/agents.mjs +914 -0
- package/lib/atomic.mjs +96 -0
- package/lib/auth.mjs +1491 -0
- package/lib/authenticatedHttp.mjs +75 -0
- package/lib/boardside.mjs +225 -0
- package/lib/child.mjs +1292 -0
- package/lib/cli-output.mjs +176 -0
- package/lib/cloudflared-release.json +25 -0
- package/lib/cloudflared.mjs +81 -0
- package/lib/codex-rpc.mjs +92 -0
- package/lib/codex.mjs +767 -0
- package/lib/codexApps.mjs +344 -0
- package/lib/codexPlanWindow.mjs +84 -0
- package/lib/codexToolInputs.mjs +139 -0
- package/lib/codingChecks.mjs +55 -0
- package/lib/compose.mjs +76 -0
- package/lib/computerApps.mjs +110 -0
- package/lib/conversationRules.mjs +45 -0
- package/lib/detect.mjs +629 -0
- package/lib/devserver.mjs +670 -0
- package/lib/digest.mjs +290 -0
- package/lib/doctor.mjs +464 -0
- package/lib/exitClass.mjs +64 -0
- package/lib/hosted.mjs +112 -0
- package/lib/imageAttachments.mjs +31 -0
- package/lib/init-connect.mjs +418 -0
- package/lib/kill.mjs +104 -0
- package/lib/logindaemon.mjs +976 -0
- package/lib/logs.mjs +120 -0
- package/lib/mcpjson.mjs +242 -0
- package/lib/memberPreview.mjs +173 -0
- package/lib/multiwatch.mjs +428 -0
- package/lib/pagePictures.mjs +396 -0
- package/lib/pageRoutes.mjs +70 -0
- package/lib/paths.mjs +10 -0
- package/lib/permissionAsk.mjs +418 -0
- package/lib/picker.mjs +300 -0
- package/lib/platform.mjs +60 -0
- package/lib/previewGateway.mjs +130 -0
- package/lib/previewTunnel.mjs +85 -0
- package/lib/procs.mjs +428 -0
- package/lib/project-mcp.mjs +288 -0
- package/lib/projectPaths.mjs +31 -0
- package/lib/refusal.mjs +70 -0
- package/lib/registry.mjs +204 -0
- package/lib/repomap.mjs +323 -0
- package/lib/restartPass.mjs +151 -0
- package/lib/retired-address.mjs +78 -0
- package/lib/rowsAnswers.mjs +388 -0
- package/lib/rowsEvents.mjs +86 -0
- package/lib/run-engine.mjs +16 -0
- package/lib/runner-health.mjs +57 -0
- package/lib/runner.mjs +1640 -0
- package/lib/runnerAdmission.mjs +35 -0
- package/lib/runnerApi.mjs +312 -0
- package/lib/runnerCompletion.mjs +406 -0
- package/lib/runnerLease.mjs +37 -0
- package/lib/runnerMaintenance.mjs +27 -0
- package/lib/runnerPace.mjs +283 -0
- package/lib/runnerPerformance.mjs +26 -0
- package/lib/runnerRealtime.mjs +340 -0
- package/lib/runnerRegister.mjs +96 -0
- package/lib/runnerSettings.mjs +40 -0
- package/lib/runnerSettlement.mjs +145 -0
- package/lib/runnerWorkspace.mjs +177 -0
- package/lib/serverSweep.mjs +90 -0
- package/lib/sharedPreview.mjs +292 -0
- package/lib/signIn.mjs +47 -0
- package/lib/spawn.mjs +330 -0
- package/lib/sweep.mjs +161 -0
- package/lib/sync.mjs +484 -0
- package/lib/uninstall-project.mjs +589 -0
- package/lib/uninstall.mjs +464 -0
- package/lib/update.mjs +302 -0
- package/lib/usageLimit.mjs +108 -0
- package/lib/viewableFolder.mjs +105 -0
- package/lib/windows-command.mjs +85 -0
- package/lib/worktree.mjs +1142 -0
- package/lib/writePolicy.mjs +45 -0
- package/package.json +58 -4
- package/spawn-runner.mjs +167 -0
package/lib/logs.mjs
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// THE DAEMON'S LOG, because a background process with no terminal has nowhere
|
|
2
|
+
// else to say anything.
|
|
3
|
+
//
|
|
4
|
+
// Until brief 19 the runner was a thing a person typed, so its output went to
|
|
5
|
+
// that person's terminal and there was nothing to keep. A LaunchAgent has no
|
|
6
|
+
// terminal, so the same lines go to ~/.glytch/logs/runner.log - and a file
|
|
7
|
+
// something appends to for ever is a disk leak, so it rotates by SIZE.
|
|
8
|
+
//
|
|
9
|
+
// Rotation is by size and not by date on purpose: a machine that spawns
|
|
10
|
+
// nothing writes a few lines a day and should keep months of them in one
|
|
11
|
+
// file, while a machine running sessions all night fills a file in hours. The
|
|
12
|
+
// budget that matters is the disk, so the budget is the one that is measured.
|
|
13
|
+
|
|
14
|
+
import { appendFile, mkdir, rename, rm, stat } from "node:fs/promises";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
import { viewableHome } from "./registry.mjs";
|
|
17
|
+
|
|
18
|
+
/** 5 MB per file, three rotations kept: at most ~20 MB of logs on a machine,
|
|
19
|
+
* which is small enough never to be noticed and large enough to hold the
|
|
20
|
+
* night a session went wrong. */
|
|
21
|
+
export const LOG_MAX_BYTES = 5 * 1024 * 1024;
|
|
22
|
+
export const LOG_KEEP = 3;
|
|
23
|
+
|
|
24
|
+
export const logDir = (env = process.env) => join(viewableHome(env), "logs");
|
|
25
|
+
export const logPath = (env = process.env) => join(logDir(env), "runner.log");
|
|
26
|
+
|
|
27
|
+
const sizeOf = async (path) => {
|
|
28
|
+
try {
|
|
29
|
+
return (await stat(path)).size;
|
|
30
|
+
} catch {
|
|
31
|
+
return 0;
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/** Roll runner.log to runner.log.1, .1 to .2, and drop what falls past `keep`.
|
|
36
|
+
*
|
|
37
|
+
* Oldest first, so nothing overwrites a file it is about to move. Returns true
|
|
38
|
+
* when a rotation actually happened. Never throws: losing a log line is not a
|
|
39
|
+
* reason to take the daemon down. */
|
|
40
|
+
export async function rotateIfNeeded(
|
|
41
|
+
{ max = LOG_MAX_BYTES, keep = LOG_KEEP, env = process.env } = {},
|
|
42
|
+
) {
|
|
43
|
+
const path = logPath(env);
|
|
44
|
+
if ((await sizeOf(path)) < max) return false;
|
|
45
|
+
try {
|
|
46
|
+
await rm(`${path}.${keep}`, { force: true });
|
|
47
|
+
for (let n = keep - 1; n >= 1; n -= 1) {
|
|
48
|
+
try {
|
|
49
|
+
await rename(`${path}.${n}`, `${path}.${n + 1}`);
|
|
50
|
+
} catch {
|
|
51
|
+
/* that rotation does not exist yet */
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
await rename(path, `${path}.1`);
|
|
55
|
+
return true;
|
|
56
|
+
} catch {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Every rotated file beyond `keep`, so the sweep can remove what an older
|
|
62
|
+
* keep count (or a crash mid-rotation) left behind. Names only. */
|
|
63
|
+
export async function staleRotations({ keep = LOG_KEEP, env = process.env } = {}) {
|
|
64
|
+
const path = logPath(env);
|
|
65
|
+
const stale = [];
|
|
66
|
+
for (let n = keep + 1; n <= keep + 20; n += 1) {
|
|
67
|
+
if ((await sizeOf(`${path}.${n}`)) > 0) stale.push(`${path}.${n}`);
|
|
68
|
+
else {
|
|
69
|
+
try {
|
|
70
|
+
await stat(`${path}.${n}`);
|
|
71
|
+
stale.push(`${path}.${n}`);
|
|
72
|
+
} catch {
|
|
73
|
+
/* not there */
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return stale;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Should a log line ALSO be written to stdout, on top of the file the caller
|
|
81
|
+
* always gets? (2026-09-21) The launchd/systemd plist that runs `glytch
|
|
82
|
+
* runner watch` as the daemon redirects that process's OWN stdout into the
|
|
83
|
+
* same `runner.log` file (`lib/logindaemon.mjs`'s `>> "$out" 2>&1`), so an
|
|
84
|
+
* echo there is not "also print it" - it is a second `appendFile` of the
|
|
85
|
+
* same line, doubling every line the daemon ever writes. A person running
|
|
86
|
+
* `viewable runner watch` by hand in a terminal has no such redirect and still
|
|
87
|
+
* wants to see what is happening, and `process.stdout.isTTY` is exactly the
|
|
88
|
+
* fact that tells the two apart: a terminal sets it, a file/pipe redirect
|
|
89
|
+
* does not. `GLYTCH_LOG_ECHO=1` overrides it either way, for a person who
|
|
90
|
+
* redirects `runner watch` somewhere else by hand and still wants the echo.
|
|
91
|
+
* `isTTY` is a parameter (not read from `process.stdout` inline) so this is
|
|
92
|
+
* provable without a real terminal. */
|
|
93
|
+
export function shouldEchoToStdout({ isTTY = process.stdout.isTTY, env = process.env } = {}) {
|
|
94
|
+
if ((env.VIEWABLE_LOG_ECHO ?? env.GLYTCH_LOG_ECHO) === "1") return true;
|
|
95
|
+
return Boolean(isTTY);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** A `log` function of the shape every function in this package already takes
|
|
99
|
+
* (`(line) => void`), writing timestamped lines into the rotating file. It
|
|
100
|
+
* also echoes to stdout by default when stdout is a real terminal
|
|
101
|
+
* (`shouldEchoToStdout`) - `viewable runner watch` run by hand must still say
|
|
102
|
+
* what it is doing, while the same process under the daemon's plist (stdout
|
|
103
|
+
* already redirected into this same file) must not write every line twice.
|
|
104
|
+
* `echo` still overrides outright when a caller (a test, `GLYTCH_LOG_ECHO`)
|
|
105
|
+
* has already decided. */
|
|
106
|
+
export function makeFileLogger({ env = process.env, echo, max = LOG_MAX_BYTES, keep = LOG_KEEP } = {}) {
|
|
107
|
+
const doEcho = echo ?? shouldEchoToStdout({ env });
|
|
108
|
+
let queue = Promise.resolve();
|
|
109
|
+
return (line = "") => {
|
|
110
|
+
if (doEcho) process.stdout.write(`${line}\n`);
|
|
111
|
+
queue = queue
|
|
112
|
+
.then(async () => {
|
|
113
|
+
await mkdir(logDir(env), { recursive: true });
|
|
114
|
+
await rotateIfNeeded({ max, keep, env });
|
|
115
|
+
await appendFile(logPath(env), `${new Date().toISOString()} ${line}\n`, "utf8");
|
|
116
|
+
})
|
|
117
|
+
.catch(() => {});
|
|
118
|
+
return queue;
|
|
119
|
+
};
|
|
120
|
+
}
|
package/lib/mcpjson.mjs
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
// Fresh configs write viewable (#2328). Existing glytch entries stay readable and keep their name (#2196).
|
|
2
|
+
// .mcp.json, as `glytch init` writes it.
|
|
3
|
+
//
|
|
4
|
+
// ONE WRITER SINCE 2026-10-15 (#494). The app had a second copy of these
|
|
5
|
+
// rules (src/folder/mcpConfig.ts) behind a Help button; that button went
|
|
6
|
+
// with the old Help, and its copy with it. The rules are pinned by
|
|
7
|
+
// bridge/test/fixtures/mcp-json-cases.json, read by bridge/test/mcpjson.test.mjs.
|
|
8
|
+
//
|
|
9
|
+
// THE FIXTURE BRIEFLY DIVERGED HERE, 2026-09-12 (brief 31, Move 3): this side
|
|
10
|
+
// took the `glytch` rename first, writing the key below, and the fixture was
|
|
11
|
+
// forked into mcp-json-cases.bridge.json so `src/folder/mcpConfig.ts` (which
|
|
12
|
+
// still wrote `board`) would not break. REUNITED the same day (Move 3b): that
|
|
13
|
+
// file now writes `glytch` too, with the same rename-in-place and cleanup
|
|
14
|
+
// rules, so this fixture is shared again and the fork is gone.
|
|
15
|
+
//
|
|
16
|
+
// The rules, all three of them, are the app's and are quoted from it:
|
|
17
|
+
//
|
|
18
|
+
// PRESENCE IS THE KEY, NOT THE VALUE. A dev checkout named a file, a
|
|
19
|
+
// stranger's named the installed binary. Both are correct, so a `glytch`
|
|
20
|
+
// key in any shape means connected and nothing is written (one exception,
|
|
21
|
+
// 2026-10-03: a url at the retired address is moved, see below).
|
|
22
|
+
//
|
|
23
|
+
// A FILE WE CANNOT PARSE IS NEVER REPLACED. It is someone's work.
|
|
24
|
+
//
|
|
25
|
+
// A FILE WITH NO mcpServers KEY IS STILL SOMEONE'S CONFIG, so the key is
|
|
26
|
+
// added to it rather than the file being replaced.
|
|
27
|
+
//
|
|
28
|
+
// RENAMED 2026-09-12 (brief 31, Move 3, ruling 3): the key WRITTEN is
|
|
29
|
+
// `glytch`, not `board` - "board" is the product the user thinks on, "glytch"
|
|
30
|
+
// is what the server is called from here on. An existing `board` entry
|
|
31
|
+
// whose `url` is ours (`BOARD_MCP_URL`) is recognised as this package's own
|
|
32
|
+
// and renamed to `glytch` IN PLACE, the same upgrade path a legacy
|
|
33
|
+
// `command`/`args` entry already took - `mergeBoardServer` below treats
|
|
34
|
+
// "ours by url" and "ours by legacy command" as the same `kind:"legacy"`
|
|
35
|
+
// outcome, because both mean "this is our entry, just under the old key."
|
|
36
|
+
// A `board` entry that is neither (a stranger's own remote server, still
|
|
37
|
+
// legitimately named "board") is left exactly alone, `kind:"present"`,
|
|
38
|
+
// same as it always was. And a folder that already has BOTH - a `glytch`
|
|
39
|
+
// entry from a previous `init` plus a leftover `board` entry this rename
|
|
40
|
+
// left behind - gets the stale `board` table dropped on the next run.
|
|
41
|
+
//
|
|
42
|
+
// MOVED 2026-10-24: the board uses `https://app.viewable.one/mcp`; both older
|
|
43
|
+
// app hosts are retired (a Cloudflare redirect sends it to app, but a
|
|
44
|
+
// cross-origin redirect drops the Authorization header, so an agent config
|
|
45
|
+
// still naming the old address stops working rather than following). An entry
|
|
46
|
+
// at either RETIRED_BOARD_MCP_URLS address is ours exactly like one at the current URL, and
|
|
47
|
+
// wherever a merge keeps an entry of ours its `url` is replaced with the
|
|
48
|
+
// current one, keeping its other fields (`moveRetiredUrl` below). An entry at
|
|
49
|
+
// any OTHER url (a developer's GLYTCH_BOARD_URL, a stranger's server) is not
|
|
50
|
+
// touched.
|
|
51
|
+
//
|
|
52
|
+
// THE SHAPE NOW is one hosted endpoint, not a spawned binary: the board lives
|
|
53
|
+
// behind `https://app.viewable.one/mcp` (a streamable-HTTP MCP server, OAuth
|
|
54
|
+
// with dynamic client registration - routes.json rewrites `/mcp` and the
|
|
55
|
+
// `/.well-known/` and `/oauth/*` discovery routes to the `board-mcp` and
|
|
56
|
+
// `board-oauth` Supabase functions), so there is no more per-platform branch:
|
|
57
|
+
// an HTTP url needs no shell to spawn it, so ruling 2A's `cmd /c` wrapper
|
|
58
|
+
// does not exist on this shape at all. `BOARD_MCP_URL` is read at call time,
|
|
59
|
+
// not fixed at import time, so a test (or a developer's shell) can point it
|
|
60
|
+
// at another deployment with `GLYTCH_BOARD_URL` without a second copy of the
|
|
61
|
+
// default living anywhere else. `env` rather than `platform` is the only
|
|
62
|
+
// axis every writer below now takes, for the same reason ruling 2A took
|
|
63
|
+
// `platform` before it: the axis a shape can vary on has to be an argument,
|
|
64
|
+
// not an assumption about the machine running this code.
|
|
65
|
+
import { runnerApiUrlFrom } from "./runnerApi.mjs";
|
|
66
|
+
import { DEFAULT_APP_URL } from "./auth.mjs";
|
|
67
|
+
import { RETIRED_APP_URL, RETIRED_APP_URLS } from "./retired-address.mjs";
|
|
68
|
+
|
|
69
|
+
export const DEFAULT_BOARD_MCP_URL = "https://app.viewable.one/mcp";
|
|
70
|
+
/** The address this package wrote until 2026-10-03. ONE definition: every
|
|
71
|
+
* other bridge file imports it from here. */
|
|
72
|
+
export const RETIRED_BOARD_MCP_URL = `${RETIRED_APP_URL}/mcp`;
|
|
73
|
+
export const RETIRED_BOARD_MCP_URLS = RETIRED_APP_URLS.map(origin => `${origin}/mcp`);
|
|
74
|
+
export const BOARD_MCP_URL = (env = process.env) => {
|
|
75
|
+
const value = (env?.VIEWABLE_BOARD_URL ?? env?.GLYTCH_BOARD_URL);
|
|
76
|
+
return typeof value === "string" && value.trim() !== "" ? value.trim() : DEFAULT_BOARD_MCP_URL;
|
|
77
|
+
};
|
|
78
|
+
/** Run children already have a bearer, so use the runner's direct function
|
|
79
|
+
* host for the production MCP endpoint. Keep discovered/custom endpoints.
|
|
80
|
+
* The hosted project address stays owned by runnerApiUrlFrom (#813). */
|
|
81
|
+
export function runnerMcpUrlFrom({ appUrl, mcpUrl }, env = process.env) {
|
|
82
|
+
if (appUrl !== DEFAULT_APP_URL || mcpUrl !== DEFAULT_BOARD_MCP_URL) return mcpUrl;
|
|
83
|
+
const runnerUrl = new URL(runnerApiUrlFrom(appUrl, env));
|
|
84
|
+
// A trailing slash still names the runner endpoint, not a directory.
|
|
85
|
+
runnerUrl.pathname = runnerUrl.pathname.replace(/\/+$/, "");
|
|
86
|
+
return new URL("board-mcp", runnerUrl).href;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Seconds Codex lets one board tool call run. check_answer can hold a call
|
|
90
|
+
* up to 50s waiting for the person's answer; Codex's own default is 60s. */
|
|
91
|
+
export const CODEX_TOOL_TIMEOUT_SEC = 120;
|
|
92
|
+
|
|
93
|
+
/** THE SHAPE EVERY WRITER PRODUCES NOW, on every platform: a remote MCP
|
|
94
|
+
* server, described the way Claude Code's own `claude mcp add --transport
|
|
95
|
+
* http` produces one. `env` stays a parameter (rather than reading
|
|
96
|
+
* `process.env` inline) for the same reason `boardServerFor` used to take
|
|
97
|
+
* `platform`: a test, or `GLYTCH_BOARD_URL` in a developer's shell, needs to
|
|
98
|
+
* say which URL without a second implementation. */
|
|
99
|
+
export const boardServerFor = (env = process.env) => ({ type: "http", url: BOARD_MCP_URL(env) });
|
|
100
|
+
|
|
101
|
+
const isObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
102
|
+
|
|
103
|
+
// Two-space JSON with a trailing newline - what every editor and every
|
|
104
|
+
// formatter in this repo writes, so adding one entry does not show up as a
|
|
105
|
+
// whole-file diff.
|
|
106
|
+
const render = (value) => `${JSON.stringify(value, null, 2)}\n`;
|
|
107
|
+
|
|
108
|
+
/** Is this `command`/`args` pair one of the shapes THIS PACKAGE ITSELF wrote
|
|
109
|
+
* before the local server was retired? Recognised regardless of which writer
|
|
110
|
+
* produced it or which platform it ran on:
|
|
111
|
+
*
|
|
112
|
+
* - `pm-agent` (a bin alias every version up to 0.6.0 installed, spawned
|
|
113
|
+
* bare; 0.7.0 stopped shipping it, which is exactly why configs naming
|
|
114
|
+
* it have to be found and rewritten)
|
|
115
|
+
* - `glytch` with `mcp` among its args (ruling 2 of brief 19, POSIX)
|
|
116
|
+
* - `cmd` with `glytch` and `mcp` among its args (ruling 2A, native Windows)
|
|
117
|
+
* - `node` with an arg ending `pm-agent.mjs` (a dev checkout's own spelling
|
|
118
|
+
* of the same server)
|
|
119
|
+
*
|
|
120
|
+
* Anything else - a custom command, a disabled entry, someone's own remote
|
|
121
|
+
* server named "board" - is NOT recognised, and stays exactly as the
|
|
122
|
+
* presence rule above has always left it. This is the one place that
|
|
123
|
+
* definition lives; lib/project-mcp.mjs imports it rather than repeating it,
|
|
124
|
+
* so Codex and opencode upgrade the same shapes .mcp.json does. */
|
|
125
|
+
export function isLegacyBoardCommand(command, args) {
|
|
126
|
+
const list = Array.isArray(args) ? args.filter((a) => typeof a === "string") : [];
|
|
127
|
+
if (command === "pm-agent") return true;
|
|
128
|
+
if (command === "glytch" && list.includes("mcp")) return true;
|
|
129
|
+
if (command === "cmd" && list.includes("glytch") && list.includes("mcp")) return true;
|
|
130
|
+
if (command === "node" && list.some((a) => a.replace(/\\/g, "/").endsWith("pm-agent.mjs"))) return true;
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Is this entry sitting at the retired address, and is that address not the
|
|
135
|
+
* one this run is pointed at (a developer who set GLYTCH_BOARD_URL to the old
|
|
136
|
+
* host on purpose has nothing to move)? Shared by every writer: Codex's TOML
|
|
137
|
+
* and opencode's JSON carry the same `url` key. */
|
|
138
|
+
export function entryIsAtRetiredUrl(entry, env) {
|
|
139
|
+
return isObject(entry) && RETIRED_BOARD_MCP_URLS.includes(entry.url) && BOARD_MCP_URL(env) !== entry.url;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** The entry with its retired `url` replaced by the current one and every
|
|
143
|
+
* other field kept; any other entry comes back as the same object. */
|
|
144
|
+
export function moveRetiredUrl(entry, env) {
|
|
145
|
+
return entryIsAtRetiredUrl(entry, env) ? { ...entry, url: BOARD_MCP_URL(env) } : entry;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Is this `board` entry ours - either the old spawned-command shape
|
|
149
|
+
* (`isLegacyBoardCommand`) or already the hosted-URL shape this package
|
|
150
|
+
* itself wrote before the rename (`url` matches `BOARD_MCP_URL`, or the
|
|
151
|
+
* retired address this package wrote before 2026-10-03)? Either
|
|
152
|
+
* way it is safe to rename to `glytch` rather than a stranger's own server
|
|
153
|
+
* that merely happens to be named "board".
|
|
154
|
+
*
|
|
155
|
+
* EXPORTED (brief 31 review, 2026-09-12) so lib/uninstall-project.mjs can
|
|
156
|
+
* use this exact check for `.mcp.json` rather than keeping its own
|
|
157
|
+
* byte-identical copy - the removal side of this file's merge needs the
|
|
158
|
+
* same "is this ours" answer the merge itself does. */
|
|
159
|
+
export function boardEntryIsOurs(entry, env) {
|
|
160
|
+
if (!isObject(entry)) return false;
|
|
161
|
+
if (isLegacyBoardCommand(entry.command, entry.args)) return true;
|
|
162
|
+
return typeof entry.url === "string" && (entry.url === BOARD_MCP_URL(env) || RETIRED_BOARD_MCP_URLS.includes(entry.url));
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Decide what to write, given whatever is on disk (null = no file).
|
|
166
|
+
*
|
|
167
|
+
* Returns one of:
|
|
168
|
+
* {kind:"create", text} - no file at all; `text` is a whole new one
|
|
169
|
+
* {kind:"add", text} - a file with other servers; `text` is it plus glytch
|
|
170
|
+
* {kind:"legacy", text} - an existing `board` entry is OUR OWN, either the
|
|
171
|
+
* retired spawned-command shape
|
|
172
|
+
* (isLegacyBoardCommand) or the hosted-URL shape
|
|
173
|
+
* written under the old key; `text` is it
|
|
174
|
+
* renamed to `glytch` in place (a legacy command
|
|
175
|
+
* entry is replaced with the canonical server
|
|
176
|
+
* value; a url-match entry keeps its own other
|
|
177
|
+
* fields). Also covers the cleanup case where
|
|
178
|
+
* both `glytch` and a stale `board` (ours) are
|
|
179
|
+
* present - `text` drops the leftover `board`.
|
|
180
|
+
* `moved:true` is added when the retired address
|
|
181
|
+
* was replaced (so a caller's message can say
|
|
182
|
+
* "moved to app.viewable.one", not "upgraded from
|
|
183
|
+
* the old command shape"). An existing `glytch`
|
|
184
|
+
* entry at the retired address is rewritten the
|
|
185
|
+
* same way; one at any other url is untouched.
|
|
186
|
+
* {kind:"present"} - already has a `board` server we do not
|
|
187
|
+
* recognise as ours, or already has `glytch`
|
|
188
|
+
* with no stale `board` to clean up; nothing
|
|
189
|
+
* touched
|
|
190
|
+
* {kind:"unreadable"} - there is a file and we cannot extend it; never
|
|
191
|
+
* written over
|
|
192
|
+
*/
|
|
193
|
+
export function mergeBoardServer(existing, env = process.env) {
|
|
194
|
+
const server = boardServerFor(env);
|
|
195
|
+
if (existing === null || existing === undefined || existing.trim() === "") {
|
|
196
|
+
return { kind: "create", text: render({ mcpServers: { viewable: server } }) };
|
|
197
|
+
}
|
|
198
|
+
let parsed;
|
|
199
|
+
try {
|
|
200
|
+
parsed = JSON.parse(existing);
|
|
201
|
+
} catch {
|
|
202
|
+
return { kind: "unreadable" };
|
|
203
|
+
}
|
|
204
|
+
if (!isObject(parsed)) return { kind: "unreadable" };
|
|
205
|
+
|
|
206
|
+
const servers = parsed.mcpServers;
|
|
207
|
+
if (servers === undefined) {
|
|
208
|
+
return { kind: "add", text: render({ ...parsed, mcpServers: { viewable: server } }) };
|
|
209
|
+
}
|
|
210
|
+
if (!isObject(servers)) return { kind: "unreadable" };
|
|
211
|
+
|
|
212
|
+
// Read old, write new: keep either existing product entry without duplicating it (#2196).
|
|
213
|
+
if ("viewable" in servers) return { kind: "present" };
|
|
214
|
+
const boardEntry = servers.board;
|
|
215
|
+
const boardIsOurs = boardEntryIsOurs(boardEntry, env);
|
|
216
|
+
|
|
217
|
+
if (!("glytch" in servers)) {
|
|
218
|
+
if (!("board" in servers)) {
|
|
219
|
+
return { kind: "add", text: render({ ...parsed, mcpServers: { ...servers, viewable: server } }) };
|
|
220
|
+
}
|
|
221
|
+
if (!boardIsOurs) return { kind: "present" };
|
|
222
|
+
const { board: _dropped, ...rest } = servers;
|
|
223
|
+
const renamed = isLegacyBoardCommand(boardEntry.command, boardEntry.args) ? server : moveRetiredUrl(boardEntry, env);
|
|
224
|
+
return {
|
|
225
|
+
kind: "legacy",
|
|
226
|
+
...(entryIsAtRetiredUrl(boardEntry, env) ? { moved: true } : {}),
|
|
227
|
+
text: render({ ...parsed, mcpServers: { ...rest, glytch: renamed } }),
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// `glytch` is already there. A leftover `board` entry that is ours (from
|
|
232
|
+
// before this rename shipped, or from a folder init'd between the two) is
|
|
233
|
+
// dropped; anything else about the file, including a stranger's own
|
|
234
|
+
// "board" server, is left exactly as it is. A `glytch` entry at the retired
|
|
235
|
+
// address is ours too, and moves to the current one.
|
|
236
|
+
const glytchMoved = entryIsAtRetiredUrl(servers.glytch, env);
|
|
237
|
+
if (!boardIsOurs && !glytchMoved) return { kind: "present" };
|
|
238
|
+
const { board: _dropped, ...rest } = servers;
|
|
239
|
+
const next = boardIsOurs ? rest : { ...servers };
|
|
240
|
+
if (glytchMoved) next.glytch = moveRetiredUrl(servers.glytch, env);
|
|
241
|
+
return { kind: "legacy", ...(glytchMoved ? { moved: true } : {}), text: render({ ...parsed, mcpServers: next }) };
|
|
242
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/** Board-member authorization around an outbound preview transport. No bearer
|
|
2
|
+
* Supabase session ever leaves the app: this verifies preview-scoped grants. */
|
|
3
|
+
import { createPublicKey, randomBytes, randomUUID, verify } from "node:crypto";
|
|
4
|
+
import { createPreviewGateway } from "./previewGateway.mjs";
|
|
5
|
+
import { startQuickPreviewTunnel } from "./previewTunnel.mjs";
|
|
6
|
+
|
|
7
|
+
export const PREVIEW_COOKIE = "__viewable_preview";
|
|
8
|
+
const LEGACY_PREVIEW_COOKIE = "__glytch_preview";
|
|
9
|
+
const AUTH_PATH = "/__viewable_preview/auth";
|
|
10
|
+
const SESSION_PATH = "/__viewable_preview/session";
|
|
11
|
+
|
|
12
|
+
export function previewAuthConfig(env = process.env) {
|
|
13
|
+
if (!env.PREVIEW_VERIFY_JWK || !env.PM_PREVIEW_APP_ORIGINS) {
|
|
14
|
+
throw new Error("Shared preview access is not configured. Set PREVIEW_VERIFY_JWK and PM_PREVIEW_APP_ORIGINS on this runner after deploying preview-grant.");
|
|
15
|
+
}
|
|
16
|
+
let key;
|
|
17
|
+
try {
|
|
18
|
+
const jwk = JSON.parse(env.PREVIEW_VERIFY_JWK);
|
|
19
|
+
if (jwk.kty !== "RSA" || jwk.d) throw new Error("public RSA key required");
|
|
20
|
+
key = createPublicKey({ key: jwk, format: "jwk" });
|
|
21
|
+
if (key.asymmetricKeyDetails.modulusLength < 2048) throw new Error("RSA key too small");
|
|
22
|
+
} catch { throw new Error("PREVIEW_VERIFY_JWK must contain a public RSA JWK of at least 2048 bits."); }
|
|
23
|
+
const appOrigins = env.PM_PREVIEW_APP_ORIGINS.split(",").map((s) => s.trim()).filter(Boolean);
|
|
24
|
+
if (!appOrigins.length || appOrigins.some((s) => {
|
|
25
|
+
try {
|
|
26
|
+
const u = new URL(s);
|
|
27
|
+
return u.origin !== s || u.hostname.includes("*") || (u.protocol !== "https:" && !(u.protocol === "http:" && ["localhost", "127.0.0.1"].includes(u.hostname)));
|
|
28
|
+
} catch { return true; }
|
|
29
|
+
})) throw new Error("PM_PREVIEW_APP_ORIGINS must list exact HTTPS app origins (HTTP loopback is allowed for local development).");
|
|
30
|
+
return { key, appOrigins };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function verifyPreviewGrant(token, key, expected, now = Date.now()) {
|
|
34
|
+
try {
|
|
35
|
+
if (typeof token !== "string" || token.length > 8192) return null;
|
|
36
|
+
const parts = token.split(".");
|
|
37
|
+
if (parts.length !== 3 || parts.some((p) => !/^[A-Za-z0-9_-]+$/.test(p))) return null;
|
|
38
|
+
const [header, claims] = parts.slice(0, 2).map((p) => JSON.parse(Buffer.from(p, "base64url").toString("utf8")));
|
|
39
|
+
if (header.alg !== "RS256" || header.typ !== "JWT" || header.crit) return null;
|
|
40
|
+
if (!verify("RSA-SHA256", Buffer.from(`${parts[0]}.${parts[1]}`), key, Buffer.from(parts[2], "base64url"))) return null;
|
|
41
|
+
if (!["viewable-preview", "glytch-preview"].includes(claims.iss) || typeof claims.sub !== "string" || !claims.sub) return null;
|
|
42
|
+
for (const field of ["aud", "boardId", "workspaceId", "nodeId", "runKey", "previewId"]) {
|
|
43
|
+
if (typeof expected[field] !== "string" || !expected[field] || claims[field] !== expected[field]) return null;
|
|
44
|
+
}
|
|
45
|
+
if (!Number.isInteger(claims.iat) || !Number.isInteger(claims.exp) || claims.exp * 1000 <= now ||
|
|
46
|
+
claims.iat * 1000 > now + 5000 || claims.exp <= claims.iat || claims.exp - claims.iat > 60) return null;
|
|
47
|
+
return claims;
|
|
48
|
+
} catch { return null; }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function cookie(req) {
|
|
52
|
+
const parts = String(req.headers.cookie ?? "").split(";").map((s) => s.trim());
|
|
53
|
+
for (const name of [PREVIEW_COOKIE, LEGACY_PREVIEW_COOKIE]) {
|
|
54
|
+
const matches = parts.filter((s) => s.startsWith(`${name}=`));
|
|
55
|
+
if (matches.length > 1) return "";
|
|
56
|
+
if (matches.length === 1) return matches[0].slice(name.length + 1);
|
|
57
|
+
}
|
|
58
|
+
return "";
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const json = (res, status, body, extra = {}) => {
|
|
62
|
+
res.writeHead(status, { "content-type": "application/json", "cache-control": "no-store", ...extra });
|
|
63
|
+
res.end(JSON.stringify(body));
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
function authPage(appOrigins, nonce, legacy = false) {
|
|
67
|
+
// Only the dedicated parent iframe can exchange a grant. Neither grant nor
|
|
68
|
+
// the application session token appears in a URL, referrer, or app response.
|
|
69
|
+
const prefix = legacy ? "glytch" : "viewable";
|
|
70
|
+
const origins = JSON.stringify(appOrigins).replaceAll("<", "\\u003c");
|
|
71
|
+
return `<!doctype html><meta charset="utf-8"><title>Preview access</title><script nonce="${nonce}">
|
|
72
|
+
const origins = ${origins};
|
|
73
|
+
addEventListener('message', async (event) => {
|
|
74
|
+
if (event.source !== parent || !origins.includes(event.origin) || event.data?.type !== '${prefix}-preview-grant') return;
|
|
75
|
+
try {
|
|
76
|
+
const response = await fetch('${SESSION_PATH}', {method:'POST', credentials:'include', headers:{'content-type':'application/json'}, body:JSON.stringify({grant:event.data.grant})});
|
|
77
|
+
const result = await response.json();
|
|
78
|
+
if (!response.ok) throw Error();
|
|
79
|
+
const confirmed = await fetch('${SESSION_PATH}', {credentials:'include',cache:'no-store'});
|
|
80
|
+
if (!confirmed.ok) throw Error();
|
|
81
|
+
parent.postMessage({type:'${prefix}-preview-authorized',expiresAt:result.expiresAt}, event.origin);
|
|
82
|
+
} catch { parent.postMessage({type:'${prefix}-preview-error'}, event.origin); }
|
|
83
|
+
});
|
|
84
|
+
for (const origin of origins) parent.postMessage({type:'${prefix}-preview-ready'}, origin);
|
|
85
|
+
</script>`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Tests inject transport, not authorization. Production always checks grants. */
|
|
89
|
+
export async function createMemberPreviewTunnel(origin, context, {
|
|
90
|
+
env = process.env, startTransport = startQuickPreviewTunnel, now = Date.now,
|
|
91
|
+
} = {}) {
|
|
92
|
+
context.signal?.throwIfAborted();
|
|
93
|
+
const { key, appOrigins } = previewAuthConfig(env);
|
|
94
|
+
for (const field of ["cloudBoardId", "workspaceId", "nodeId", "runKey"]) {
|
|
95
|
+
if (typeof context[field] !== "string" || !context[field]) throw new Error(`Shared preview needs ${field}.`);
|
|
96
|
+
}
|
|
97
|
+
const previewId = randomUUID();
|
|
98
|
+
let publicOrigin;
|
|
99
|
+
const expected = () => ({ aud: publicOrigin, boardId: context.cloudBoardId, workspaceId: context.workspaceId,
|
|
100
|
+
nodeId: context.nodeId, runKey: context.runKey, previewId });
|
|
101
|
+
const check = (token) => publicOrigin && verifyPreviewGrant(token, key, expected(), now());
|
|
102
|
+
const gateway = await createPreviewGateway({
|
|
103
|
+
target: origin,
|
|
104
|
+
authorize: async (req) => {
|
|
105
|
+
// A partitioned cookie is the primary browser isolation boundary. Also
|
|
106
|
+
// reject cross-origin writes/upgrades for browsers without CHIPS support.
|
|
107
|
+
const callerOrigin = req.headers.origin;
|
|
108
|
+
if (callerOrigin && callerOrigin !== publicOrigin) return false;
|
|
109
|
+
if ((!['GET', 'HEAD', 'OPTIONS'].includes(req.method) || req.headers.upgrade) && callerOrigin !== publicOrigin) return false;
|
|
110
|
+
const grant = check(cookie(req));
|
|
111
|
+
if (!grant) return false;
|
|
112
|
+
if (context.authorizeTarget && !await context.authorizeTarget()) return false;
|
|
113
|
+
// Ownership verification may take time; never forward an expired lease.
|
|
114
|
+
return grant.exp * 1000 > now() ? { expiresAt: grant.exp * 1000 } : false;
|
|
115
|
+
},
|
|
116
|
+
handleAuth: async (req, res) => {
|
|
117
|
+
const requestedPath = new URL(req.url, "http://gateway.invalid").pathname;
|
|
118
|
+
const path = requestedPath.replace(/^\/__glytch_preview\//, "/__viewable_preview/");
|
|
119
|
+
if (!path.startsWith("/__viewable_preview/")) return false;
|
|
120
|
+
if (!publicOrigin) { json(res, 503, { error: "Preview is connecting." }); return true; }
|
|
121
|
+
if (path === AUTH_PATH && req.method === "GET") {
|
|
122
|
+
const nonce = randomBytes(16).toString("base64");
|
|
123
|
+
res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store",
|
|
124
|
+
"referrer-policy": "no-referrer", "x-content-type-options": "nosniff",
|
|
125
|
+
"content-security-policy": `default-src 'none'; script-src 'nonce-${nonce}'; connect-src 'self'; frame-ancestors ${appOrigins.join(" ")}; base-uri 'none'; form-action 'none'` });
|
|
126
|
+
res.end(authPage(appOrigins, nonce, requestedPath.startsWith("/__glytch_preview/") && new URL(req.url, "http://gateway.invalid").searchParams.get("protocol") !== "viewable")); return true;
|
|
127
|
+
}
|
|
128
|
+
if (path === SESSION_PATH && req.method === "GET") {
|
|
129
|
+
const claims = check(cookie(req));
|
|
130
|
+
json(res, claims ? 200 : 403, claims ? { expiresAt: claims.exp * 1000 } : { error: "Preview access is required." });
|
|
131
|
+
return true;
|
|
132
|
+
}
|
|
133
|
+
if (path !== SESSION_PATH || req.method !== "POST" || req.headers.origin !== publicOrigin ||
|
|
134
|
+
!String(req.headers["content-type"] ?? "").startsWith("application/json")) {
|
|
135
|
+
json(res, 403, { error: "Preview access is required." }); return true;
|
|
136
|
+
}
|
|
137
|
+
const chunks = []; let length = 0;
|
|
138
|
+
for await (const chunk of req) {
|
|
139
|
+
length += chunk.length;
|
|
140
|
+
if (length > 12_000) { json(res, 413, { error: "Grant is too large." }); return true; }
|
|
141
|
+
chunks.push(chunk);
|
|
142
|
+
}
|
|
143
|
+
let grant;
|
|
144
|
+
try { grant = JSON.parse(Buffer.concat(chunks).toString("utf8")).grant; } catch { /* refused below */ }
|
|
145
|
+
const claims = check(grant);
|
|
146
|
+
if (!claims) { json(res, 403, { error: "Preview access expired or does not match this board." }); return true; }
|
|
147
|
+
json(res, 200, { expiresAt: claims.exp * 1000 }, { "set-cookie":
|
|
148
|
+
`${PREVIEW_COOKIE}=${grant}; Path=/; Max-Age=${Math.max(0, Math.floor((claims.exp * 1000 - now()) / 1000))}; Secure; HttpOnly; SameSite=None; Partitioned` });
|
|
149
|
+
return true;
|
|
150
|
+
},
|
|
151
|
+
});
|
|
152
|
+
let transport;
|
|
153
|
+
try {
|
|
154
|
+
transport = await startTransport(gateway.origin, { bin: env.PM_CLOUDFLARED_BIN, signal: context.signal });
|
|
155
|
+
context.signal?.throwIfAborted();
|
|
156
|
+
const remote = new URL(transport.url);
|
|
157
|
+
if (remote.protocol !== "https:" || remote.username || remote.password ||
|
|
158
|
+
["localhost", "127.0.0.1", "[::1]"].includes(remote.hostname)) throw new Error("Preview transport must supply a public HTTPS origin.");
|
|
159
|
+
publicOrigin = remote.origin;
|
|
160
|
+
const local = new URL(context.fullLocalUrl ?? context.localUrl ?? origin);
|
|
161
|
+
const url = `${publicOrigin}${local.pathname}${local.search}${local.hash}`;
|
|
162
|
+
let stopped = false;
|
|
163
|
+
return { previewId, url, alive: () => !stopped && (transport.alive?.() ?? true), stop: async () => {
|
|
164
|
+
if (stopped) return; stopped = true;
|
|
165
|
+
// Close the authorized gateway first even if the transport refuses to die.
|
|
166
|
+
await gateway.close(); await transport.stop();
|
|
167
|
+
} };
|
|
168
|
+
} catch (error) {
|
|
169
|
+
await gateway.close();
|
|
170
|
+
try { await transport?.stop(); } catch { /* gateway is already unreachable */ }
|
|
171
|
+
throw error;
|
|
172
|
+
}
|
|
173
|
+
}
|