pi-kanban 0.3.1 → 2.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/CHANGELOG.md +30 -0
- package/README.md +61 -21
- package/cli.js +1586 -0
- package/extensions/kanban.ts +330 -367
- package/extensions/package.json +1 -0
- package/extensions/tools/ask.ts +85 -0
- package/extensions/tools/board-api.ts +142 -0
- package/extensions/tools/cli-path.ts +34 -0
- package/extensions/tools/dispatch.ts +56 -0
- package/extensions/tools/inbox.ts +86 -0
- package/extensions/tools/kanban-command.ts +27 -0
- package/extensions/tools/scratchpad.ts +70 -0
- package/extensions/tsconfig.json +16 -0
- package/lib/agent-dir.js +20 -0
- package/lib/agent-log.js +64 -0
- package/lib/ask.js +252 -0
- package/lib/atomic-write.js +30 -0
- package/lib/board-dir.js +63 -0
- package/lib/claude-settings.js +21 -0
- package/lib/companion-host.js +142 -0
- package/lib/companions.js +136 -0
- package/lib/contain.js +78 -0
- package/lib/dispatch-reports.js +86 -0
- package/lib/file-url.js +38 -0
- package/lib/folder-dialog.js +91 -0
- package/lib/frame-policy.js +79 -0
- package/lib/git-branch.js +37 -102
- package/lib/http-error.js +7 -0
- package/lib/inline-assets.js +238 -0
- package/lib/linked-docs.js +56 -0
- package/lib/live-sessions.js +86 -0
- package/lib/model-windows.js +48 -0
- package/lib/net-guard.js +277 -0
- package/lib/one-line.js +6 -0
- package/lib/open-editor.js +173 -0
- package/lib/panes.js +213 -0
- package/lib/parsers.js +1434 -0
- package/lib/pi-agents.js +178 -0
- package/lib/pi-git-branch.js +112 -0
- package/lib/pi-models.js +34 -0
- package/lib/pi-sessions.js +240 -0
- package/lib/pi-transcript.js +354 -0
- package/lib/preview-action.js +55 -0
- package/lib/priority.js +45 -0
- package/lib/proc-stats.js +82 -0
- package/lib/retention.js +191 -0
- package/lib/scratch-files.js +47 -0
- package/lib/scratchpad-dir.js +95 -0
- package/lib/session-cache.js +32 -0
- package/lib/session-events.js +275 -0
- package/lib/session-pins.js +99 -0
- package/lib/show.js +279 -0
- package/lib/stamped-json-file.js +49 -0
- package/lib/task-counts.js +90 -0
- package/lib/task-dir.js +26 -0
- package/lib/task-store.js +44 -47
- package/lib/terminal-client.js +248 -0
- package/lib/terminal-host.js +113 -0
- package/lib/terminal.js +1116 -0
- package/lib/user-groups.js +384 -0
- package/lib/worktrees.js +132 -0
- package/package.json +39 -15
- package/public/app.js +14349 -3448
- package/public/fonts/OFL-IBM-Plex.txt +93 -0
- package/public/fonts/OFL-Playfair-Display.txt +93 -0
- package/public/fonts/fonts.css +244 -0
- package/public/fonts/ibm-plex-mono-cyrillic-400.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-cyrillic-500.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-cyrillic-600.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-cyrillic-ext-400.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-cyrillic-ext-500.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-cyrillic-ext-600.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-latin-400.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-latin-500.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-latin-600.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-latin-ext-400.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-latin-ext-500.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-latin-ext-600.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-vietnamese-400.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-vietnamese-500.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-vietnamese-600.woff2 +0 -0
- package/public/fonts/playfair-display-cyrillic-400.woff2 +0 -0
- package/public/fonts/playfair-display-latin-400.woff2 +0 -0
- package/public/fonts/playfair-display-latin-ext-400.woff2 +0 -0
- package/public/fonts/playfair-display-vietnamese-400.woff2 +0 -0
- package/public/group-tree.js +21 -0
- package/public/icons/icon-192.png +0 -0
- package/public/icons/icon-512.png +0 -0
- package/public/icons/icon-svg.svg +7 -5
- package/public/index.html +493 -328
- package/public/link-url.js +18 -0
- package/public/manifest.json +7 -7
- package/public/project-match.js +34 -0
- package/public/style.css +8301 -2251
- package/public/sw.js +4 -3
- package/public/terminal-frame.js +437 -0
- package/public/terminal.html +26 -0
- package/public/themes.css +876 -0
- package/public/tree-highlight.js +196 -0
- package/server.js +3368 -467
- package/skills/ask/SKILL.md +29 -0
- package/skills/ask/references/playground.md +25 -0
- package/skills/dispatch/SKILL.md +51 -0
- package/skills/dispatch/references/orchestration-patterns.md +50 -0
- package/skills/kanban/SKILL.md +46 -0
- package/skills/show/SKILL.md +41 -0
- package/skills/show/references/NOTICE.md +27 -0
- package/skills/show/references/chart.md +58 -0
- package/skills/show/references/diagram/architecture.md +18 -0
- package/skills/show/references/diagram/er.md +10 -0
- package/skills/show/references/diagram/flow.md +13 -0
- package/skills/show/references/diagram/layers.md +10 -0
- package/skills/show/references/diagram/sequence.md +19 -0
- package/skills/show/references/diagram/state.md +12 -0
- package/skills/show/references/diagram/swimlane.md +10 -0
- package/skills/show/references/diagram/timeline.md +9 -0
- package/skills/show/references/diagram/tree.md +10 -0
- package/skills/show/references/diagram.md +87 -0
- package/skills/show/references/explain.md +100 -0
- package/skills/show/references/html-contract.md +18 -0
- package/skills/show/references/mockup.md +30 -0
- package/skills/visualize/SKILL.md +73 -0
- package/skills/visualize/blocks.html +68 -0
- package/skills/visualize/template.html +179 -0
- package/lib/pi-parsers.js +0 -1000
- package/themes/kanban-dark-default.json +0 -21
- package/themes/kanban-default.json +0 -21
- package/themes/pi-dark.json +0 -21
- package/themes/pi-light.json +0 -21
package/lib/net-guard.js
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Network boundary for a local-only dev server.
|
|
4
|
+
//
|
|
5
|
+
// Binding loopback is necessary but not sufficient. Three distinct attackers:
|
|
6
|
+
//
|
|
7
|
+
// * LAN peer — http://192.168.1.42:3541/api/... The loopback bind stops this
|
|
8
|
+
// outright; the handshake never completes off-box.
|
|
9
|
+
// * DNS rebinding — the victim loads evil.com, the attacker re-resolves that
|
|
10
|
+
// name to 127.0.0.1, and the page fetches http://evil.com:3541/api/...
|
|
11
|
+
// The page's origin *is* evil.com:3541, so the request is same-origin and
|
|
12
|
+
// CORS never fires; the connection is to loopback, so the bind is satisfied.
|
|
13
|
+
// The only thing that differs from a legitimate request is the Host header,
|
|
14
|
+
// which script cannot forge. A Host allowlist is the entire defense.
|
|
15
|
+
// * Plain CSRF — evil.com POSTs straight at 127.0.0.1:3541. Host is a real
|
|
16
|
+
// allowlisted value, so hostGuard passes it; this needs a separate Origin /
|
|
17
|
+
// Sec-Fetch-Site check on state-changing methods.
|
|
18
|
+
//
|
|
19
|
+
// There is no authentication. Exposing this to a network means trusting everyone
|
|
20
|
+
// on it with the user's pi history.
|
|
21
|
+
|
|
22
|
+
const LOOPBACK_HOSTS = new Set([
|
|
23
|
+
'localhost',
|
|
24
|
+
'127.0.0.1',
|
|
25
|
+
'::1',
|
|
26
|
+
'0:0:0:0:0:0:0:1',
|
|
27
|
+
'::ffff:127.0.0.1',
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
// Hoisted: parseHostHeader runs on every request, and neither the regex nor the
|
|
31
|
+
// predicate depends on the argument.
|
|
32
|
+
const PORT_RE = /^[0-9]{1,5}$/;
|
|
33
|
+
// The port is validated but not compared — the listen(0) fallbacks make the real
|
|
34
|
+
// port dynamic, so only the host is allowlisted.
|
|
35
|
+
const validPort = (p) => PORT_RE.test(p) && Number(p) >= 1 && Number(p) <= 65535;
|
|
36
|
+
const KEEP_ALIVE_MS = 65000;
|
|
37
|
+
// A whole IPv4 literal: a prefix test would let a rebinding name like 127.evil.com through.
|
|
38
|
+
const LOOPBACK_V4_RE = /^127(?:\.(?:25[0-5]|2[0-4]\d|1?\d?\d)){3}$/;
|
|
39
|
+
|
|
40
|
+
function isLoopbackAddress(host) {
|
|
41
|
+
if (!host) return false;
|
|
42
|
+
// An Origin or URL host carries an IPv6 literal in brackets.
|
|
43
|
+
const h = String(host).toLowerCase().replace(/^\[(.*)\]$/, '$1');
|
|
44
|
+
return LOOPBACK_HOSTS.has(h) || LOOPBACK_V4_RE.test(h);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Hand-parsed rather than via new URL(), which is far more permissive than we
|
|
48
|
+
// want here and happily accepts things no real client sends. Returns the bare
|
|
49
|
+
// hostname, lowercased, or null if the header is malformed.
|
|
50
|
+
//
|
|
51
|
+
// The port is deliberately ignored: ports are dynamic in these apps because of
|
|
52
|
+
// the listen(0) fallback when the default is busy, so pinning one would break
|
|
53
|
+
// the fallback path.
|
|
54
|
+
function parseHostHeader(value) {
|
|
55
|
+
if (typeof value !== 'string') return null;
|
|
56
|
+
const raw = value.trim();
|
|
57
|
+
if (!raw || raw.length > 259) return null;
|
|
58
|
+
|
|
59
|
+
// Reject whitespace and control characters. Written as a code-point scan rather
|
|
60
|
+
// than a character class so it cannot be misread — a stray range in a regex
|
|
61
|
+
// here would silently reject legitimate hostnames containing a dash.
|
|
62
|
+
for (let i = 0; i < raw.length; i++) {
|
|
63
|
+
const code = raw.charCodeAt(i);
|
|
64
|
+
if (code <= 0x20 || code === 0x7f) return null;
|
|
65
|
+
}
|
|
66
|
+
if (raw.includes('@')) return null; // userinfo has no business in a Host header
|
|
67
|
+
|
|
68
|
+
let host;
|
|
69
|
+
if (raw.startsWith('[')) {
|
|
70
|
+
const end = raw.indexOf(']');
|
|
71
|
+
if (end === -1) return null;
|
|
72
|
+
host = raw.slice(1, end);
|
|
73
|
+
const rest = raw.slice(end + 1);
|
|
74
|
+
if (rest && !(rest.startsWith(':') && validPort(rest.slice(1)))) return null;
|
|
75
|
+
} else {
|
|
76
|
+
const parts = raw.split(':');
|
|
77
|
+
if (parts.length > 2) return null; // bare IPv6 without brackets, or garbage
|
|
78
|
+
host = parts[0];
|
|
79
|
+
if (parts.length === 2 && !validPort(parts[1])) return null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (!host) return null;
|
|
83
|
+
if (host.endsWith('.')) host = host.slice(0, -1); // one trailing root dot is legal
|
|
84
|
+
if (!host) return null;
|
|
85
|
+
return host.toLowerCase();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function parseList(value) {
|
|
89
|
+
return String(value || '')
|
|
90
|
+
.split(',')
|
|
91
|
+
.map((s) => s.trim().toLowerCase())
|
|
92
|
+
.filter(Boolean);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function getFlag(name) {
|
|
96
|
+
const idx = process.argv.findIndex((a) => a === `--${name}` || a.startsWith(`--${name}=`));
|
|
97
|
+
if (idx === -1) return null;
|
|
98
|
+
const arg = process.argv[idx];
|
|
99
|
+
if (arg.includes('=')) return arg.slice(arg.indexOf('=') + 1);
|
|
100
|
+
const next = process.argv[idx + 1];
|
|
101
|
+
return next && !next.startsWith('--') ? next : null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* @param {{appName?: string, selfFramedPaths?: string[]}} [config]
|
|
106
|
+
*/
|
|
107
|
+
function createNetGuard(config = {}) {
|
|
108
|
+
const appName = config.appName || 'This app';
|
|
109
|
+
|
|
110
|
+
const BIND_HOST = getFlag('host') || process.env.HOST || '127.0.0.1';
|
|
111
|
+
const BIND_HOST_LC = BIND_HOST.toLowerCase();
|
|
112
|
+
const EXPOSED = !isLoopbackAddress(BIND_HOST);
|
|
113
|
+
const ALLOWED_HOSTS = getFlag('allowed-hosts') || process.env.ALLOWED_HOSTS || '';
|
|
114
|
+
const extraHosts = new Set(parseList(ALLOWED_HOSTS));
|
|
115
|
+
|
|
116
|
+
// Resolved only once listen() succeeds — the requested port may have been busy.
|
|
117
|
+
let actualPort = null;
|
|
118
|
+
|
|
119
|
+
function hostAllowed(host) {
|
|
120
|
+
if (!host) return false;
|
|
121
|
+
if (isLoopbackAddress(host)) return true;
|
|
122
|
+
if (extraHosts.has(host)) return true;
|
|
123
|
+
// Binding a specific non-loopback address implies that address is a valid name for us.
|
|
124
|
+
if (EXPOSED && host === BIND_HOST_LC) return true;
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function hostGuard(req, res, next) {
|
|
129
|
+
const host = parseHostHeader(req.headers.host);
|
|
130
|
+
if (hostAllowed(host)) return next();
|
|
131
|
+
res.status(403).type('text/plain').send(
|
|
132
|
+
`403 Forbidden - unrecognized Host header: ${req.headers.host || '(none)'}\n\n` +
|
|
133
|
+
`${appName} only answers requests addressed to localhost. This protects you\n` +
|
|
134
|
+
`from DNS rebinding, where a website you visit re-points its own hostname at\n` +
|
|
135
|
+
`127.0.0.1 in order to read your local data.\n\n` +
|
|
136
|
+
`If you meant to reach this from another machine, restart with:\n` +
|
|
137
|
+
` --host 0.0.0.0 --allowed-hosts=${host || '<your-hostname>'}\n\n` +
|
|
138
|
+
`Only do that on a network you trust - there is no authentication.\n`
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// true = allow, false = block, null = no Origin header, caller decides.
|
|
143
|
+
function originVerdict(origin) {
|
|
144
|
+
if (!origin || origin === 'null') return null;
|
|
145
|
+
let parsed;
|
|
146
|
+
try { parsed = new URL(origin); } catch { return false; }
|
|
147
|
+
if (!isLoopbackAddress(parsed.hostname)) return false;
|
|
148
|
+
// Same port, any loopback spelling. A different local port is a different app
|
|
149
|
+
// and has no business driving this one.
|
|
150
|
+
if (actualPort && parsed.port && Number(parsed.port) !== Number(actualPort)) return false;
|
|
151
|
+
return true;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function originGuard(req, res, next) {
|
|
155
|
+
if (req.method === 'GET' || req.method === 'HEAD' || req.method === 'OPTIONS') return next();
|
|
156
|
+
|
|
157
|
+
const verdict = originVerdict(req.headers.origin);
|
|
158
|
+
if (verdict === true) return next();
|
|
159
|
+
if (verdict === false) {
|
|
160
|
+
return res.status(403).type('text/plain')
|
|
161
|
+
.send(`403 Forbidden - cross-origin ${req.method} from ${req.headers.origin} is not allowed.\n`);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// No Origin header: a non-browser client (curl, the CLI, the pi extension).
|
|
165
|
+
// Trust it unless the browser explicitly tells us otherwise.
|
|
166
|
+
const site = req.headers['sec-fetch-site'];
|
|
167
|
+
if (site && site !== 'same-origin' && site !== 'none') {
|
|
168
|
+
return res.status(403).type('text/plain')
|
|
169
|
+
.send(`403 Forbidden - cross-site ${req.method} (Sec-Fetch-Site: ${site}) is not allowed.\n`);
|
|
170
|
+
}
|
|
171
|
+
return next();
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// For a WebSocket upgrade, which originGuard must not judge: the handshake is a
|
|
175
|
+
// GET, and WebSocket has no CORS, so any page can open one. Browsers always send
|
|
176
|
+
// Origin on the handshake, so a missing or `null` one is refused, not trusted.
|
|
177
|
+
// Returns null when allowed, or the reason it was refused.
|
|
178
|
+
function upgradeVerdict(req) {
|
|
179
|
+
if (!hostAllowed(parseHostHeader(req.headers.host))) return 'unrecognized Host header';
|
|
180
|
+
if (originVerdict(req.headers.origin) !== true) return 'cross-origin or missing Origin';
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// These pages may be framed by this app's own port under its loopback names, for a page
|
|
185
|
+
// that frames itself from its other name to get its own renderer process.
|
|
186
|
+
const selfFramed = new Set(config.selfFramedPaths || []);
|
|
187
|
+
|
|
188
|
+
function frameGuard(req, res, next) {
|
|
189
|
+
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
190
|
+
res.setHeader('Referrer-Policy', 'no-referrer');
|
|
191
|
+
if (selfFramed.has(req.path)) {
|
|
192
|
+
const port = req.socket.localPort;
|
|
193
|
+
res.setHeader('Content-Security-Policy', `frame-ancestors 'self' http://localhost:${port} http://127.0.0.1:${port}`);
|
|
194
|
+
return next();
|
|
195
|
+
}
|
|
196
|
+
res.setHeader('Content-Security-Policy', "frame-ancestors 'none'");
|
|
197
|
+
res.setHeader('X-Frame-Options', 'DENY');
|
|
198
|
+
next();
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Bind BIND_HOST, then opportunistically serve the other loopback family on the
|
|
203
|
+
* same resolved port via a second server sharing the same request handler.
|
|
204
|
+
*
|
|
205
|
+
* That second bind is what keeps `http://localhost:<port>` working no matter how
|
|
206
|
+
* the OS resolver orders A/AAAA records - binding only 127.0.0.1 breaks hosts
|
|
207
|
+
* where `localhost` resolves to ::1, and vice versa. Doing it on the *resolved*
|
|
208
|
+
* port means every existing URL, startup banner and postMessage origin is
|
|
209
|
+
* unchanged. A family the host does not have is skipped. A port that another
|
|
210
|
+
* process holds on the other family is treated as busy: the primary closes and
|
|
211
|
+
* emits EADDRINUSE (port 0 picks another random port instead), because that process would answer `localhost:<port>` (and a
|
|
212
|
+
* cross-site frame on it) in our name. onReady waits for the second bind.
|
|
213
|
+
*
|
|
214
|
+
* Returns the primary server, so callers keep attaching their own 'error'
|
|
215
|
+
* handler for the EADDRINUSE fallback.
|
|
216
|
+
*
|
|
217
|
+
* @param {any} app express app (or any http request handler)
|
|
218
|
+
* @param {number} port 0 selects a free port
|
|
219
|
+
* @param {(port: number) => void} [onReady]
|
|
220
|
+
* `onUpgrade` is attached to both servers; a caller that attached it to the
|
|
221
|
+
* returned one alone would drop every WebSocket that arrives over the other family.
|
|
222
|
+
*
|
|
223
|
+
* @param {{maxHeaderSize?: number, onUpgrade?: (req: any, socket: any, head: Buffer) => void}} [opts]
|
|
224
|
+
*/
|
|
225
|
+
function listenLoopback(app, port, onReady, opts = {}) {
|
|
226
|
+
const http = require('node:http');
|
|
227
|
+
const serverOpts = opts.maxHeaderSize ? { maxHeaderSize: opts.maxHeaderSize } : {};
|
|
228
|
+
const makeServer = (handler) => {
|
|
229
|
+
const s = http.createServer(serverOpts, handler);
|
|
230
|
+
// Node's 5 s default closes idle sockets that the browser would reuse, and a
|
|
231
|
+
// fresh loopback connect is the one that some Windows network filters drop.
|
|
232
|
+
s.keepAliveTimeout = KEEP_ALIVE_MS;
|
|
233
|
+
if (opts.onUpgrade) s.on('upgrade', opts.onUpgrade);
|
|
234
|
+
return s;
|
|
235
|
+
};
|
|
236
|
+
const server = makeServer(app);
|
|
237
|
+
let secondary = null;
|
|
238
|
+
let randomTries = 0;
|
|
239
|
+
|
|
240
|
+
server.on('listening', () => {
|
|
241
|
+
const boundPort = server.address().port;
|
|
242
|
+
actualPort = boundPort;
|
|
243
|
+
// Not actualPort: several servers can listen through one guard, and another one can bind
|
|
244
|
+
// while this one waits for its second family.
|
|
245
|
+
const ready = () => { if (onReady) onReady(boundPort); };
|
|
246
|
+
if (EXPOSED) return ready();
|
|
247
|
+
const otherFamily = BIND_HOST.includes(':') ? '127.0.0.1' : '::1';
|
|
248
|
+
try {
|
|
249
|
+
secondary = makeServer(app);
|
|
250
|
+
secondary.once('listening', ready);
|
|
251
|
+
secondary.on('error', (err) => {
|
|
252
|
+
if (err.code !== 'EADDRINUSE') return ready();
|
|
253
|
+
// A caller asking for port 0 has no fallback of its own, so it gets another random port here.
|
|
254
|
+
if (port === 0 && ++randomTries < 5) return server.close(() => server.listen(0, BIND_HOST));
|
|
255
|
+
server.close();
|
|
256
|
+
server.emit('error', err);
|
|
257
|
+
});
|
|
258
|
+
secondary.listen({ host: otherFamily, port: boundPort, ipv6Only: otherFamily === '::1' });
|
|
259
|
+
} catch {
|
|
260
|
+
ready();
|
|
261
|
+
}
|
|
262
|
+
});
|
|
263
|
+
server.on('close', () => { try { if (secondary) secondary.close(); } catch { /* already gone */ } });
|
|
264
|
+
|
|
265
|
+
server.listen(port, BIND_HOST);
|
|
266
|
+
return server;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function exposureWarning() {
|
|
270
|
+
if (!EXPOSED) return null;
|
|
271
|
+
return `WARNING: listening on ${BIND_HOST} - reachable from your network, with no authentication.`;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
return { BIND_HOST, ALLOWED_HOSTS, EXPOSED, hostGuard, originGuard, upgradeVerdict, frameGuard, listenLoopback, exposureWarning };
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
module.exports = { createNetGuard, parseHostHeader, isLoopbackAddress, KEEP_ALIVE_MS };
|
package/lib/one-line.js
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Run a command without ever handing a string to a shell.
|
|
4
|
+
//
|
|
5
|
+
// The naive fix for command injection here — dropping `shell: true` — does not
|
|
6
|
+
// work on Windows, and fails in two different ways:
|
|
7
|
+
//
|
|
8
|
+
// * `spawn('code', args)` throws ENOENT, because the launcher on PATH is
|
|
9
|
+
// `code.cmd` and Node does not consult PATHEXT.
|
|
10
|
+
// * Resolving to `code.cmd` and spawning that throws EINVAL, because since the
|
|
11
|
+
// CVE-2024-27980 fix (Node 18.20.2 / 20.12.2 / 21.7.3+) a .bat/.cmd target
|
|
12
|
+
// may only be spawned with `shell: true` — the very thing we are removing.
|
|
13
|
+
//
|
|
14
|
+
// So on Windows we resolve the real .exe sitting behind the .cmd shim and spawn
|
|
15
|
+
// that directly. Only when no such .exe exists (npm-style shims, which are node
|
|
16
|
+
// scripts with no binary twin) do we fall back to cmd.exe, and that path applies
|
|
17
|
+
// a character allowlist so the quoting cannot be broken out of.
|
|
18
|
+
//
|
|
19
|
+
// Forked from scripts/security-lib/open-editor.js in the claude-code-hub repo; this copy is
|
|
20
|
+
// maintained here.
|
|
21
|
+
|
|
22
|
+
const { spawn } = require('node:child_process');
|
|
23
|
+
const path = require('node:path');
|
|
24
|
+
const fs = require('node:fs');
|
|
25
|
+
|
|
26
|
+
const IS_WIN = process.platform === 'win32';
|
|
27
|
+
|
|
28
|
+
// Characters that survive cmd.exe's double quotes and would let an argument
|
|
29
|
+
// escape it. Inside "..." cmd treats & | < > ^ ( ) as literal, so only these
|
|
30
|
+
// matter: '"' ends the quoted run, '%' triggers variable expansion whose value
|
|
31
|
+
// could itself contain a quote. Neither is legal in an NTFS path anyway.
|
|
32
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: NUL is one of the rejected characters
|
|
33
|
+
const WIN_CMD_FORBIDDEN = /["%\r\n\u0000]/;
|
|
34
|
+
|
|
35
|
+
function fail(message, status) {
|
|
36
|
+
return Object.assign(new Error(message), { status });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// The PATH walk below stats PATH.length × PATHEXT.length candidates — on a
|
|
40
|
+
// typical Windows box ~1200 stats, several milliseconds. The answer cannot change
|
|
41
|
+
// within a process unless PATH itself does, so it is memoized on both.
|
|
42
|
+
const whichCache = new Map();
|
|
43
|
+
|
|
44
|
+
// which(1). On Windows this walks PATHEXT so we learn the resolved extension —
|
|
45
|
+
// that .cmd-vs-.exe distinction is what selects the launch strategy below.
|
|
46
|
+
function whichSync(cmd) {
|
|
47
|
+
if (!cmd) return null;
|
|
48
|
+
if (cmd.includes('/') || cmd.includes('\\')) {
|
|
49
|
+
const abs = path.resolve(cmd);
|
|
50
|
+
try { return fs.statSync(abs).isFile() ? abs : null; } catch { return null; }
|
|
51
|
+
}
|
|
52
|
+
const key = `${process.env.PATH || ''}\u0000${process.env.PATHEXT || ''}\u0000${cmd}`;
|
|
53
|
+
if (whichCache.has(key)) return whichCache.get(key);
|
|
54
|
+
const found = whichUncached(cmd);
|
|
55
|
+
whichCache.set(key, found);
|
|
56
|
+
return found;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function whichUncached(cmd) {
|
|
60
|
+
const dirs = (process.env.PATH || '').split(path.delimiter).filter(Boolean);
|
|
61
|
+
const exts = IS_WIN
|
|
62
|
+
? (process.env.PATHEXT || '.COM;.EXE;.BAT;.CMD').split(';').filter(Boolean)
|
|
63
|
+
: [''];
|
|
64
|
+
for (const dir of dirs) {
|
|
65
|
+
for (const ext of exts) {
|
|
66
|
+
const candidate = path.join(dir, cmd + ext);
|
|
67
|
+
try { if (fs.statSync(candidate).isFile()) return candidate; } catch { /* next */ }
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// A native tool's .cmd shim normally lives at <install>/bin/foo.cmd next to
|
|
74
|
+
// <install>/foo.exe. Returns that .exe so we can skip the shell entirely.
|
|
75
|
+
// npm-installed shims have no such twin and correctly return null here.
|
|
76
|
+
function exeBehindShim(shimPath) {
|
|
77
|
+
const bin = path.dirname(shimPath);
|
|
78
|
+
const root = path.dirname(bin);
|
|
79
|
+
const stem = path.basename(shimPath, path.extname(shimPath));
|
|
80
|
+
const candidates = [path.join(root, `${stem}.exe`), path.join(bin, `${stem}.exe`)];
|
|
81
|
+
for (const candidate of candidates) {
|
|
82
|
+
try { if (fs.statSync(candidate).isFile()) return candidate; } catch { /* next */ }
|
|
83
|
+
}
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Validate a request-supplied path before it becomes a command argument, and
|
|
89
|
+
* return its resolved absolute form. Lives here rather than in each server so the
|
|
90
|
+
* rule cannot drift between the apps that share this helper.
|
|
91
|
+
*
|
|
92
|
+
* Deliberately not a containment check: opening an arbitrary project directory is
|
|
93
|
+
* the feature these endpoints exist for. The network boundary is the control.
|
|
94
|
+
*
|
|
95
|
+
* @param {unknown} value
|
|
96
|
+
* @param {string} label used in the error message
|
|
97
|
+
* @returns {string} resolved absolute path
|
|
98
|
+
*/
|
|
99
|
+
function assertOpenTarget(value, label = 'path') {
|
|
100
|
+
if (typeof value !== 'string' || !value.trim()) throw fail(`${label} must be a non-empty string`, 400);
|
|
101
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: NUL is one of the rejected characters
|
|
102
|
+
if (/[\r\n\u0000]/.test(value)) throw fail(`${label} contains invalid characters`, 400);
|
|
103
|
+
const resolved = path.resolve(value);
|
|
104
|
+
if (!fs.existsSync(resolved)) throw fail(`${label} does not exist`, 404);
|
|
105
|
+
return resolved;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Run a command without a shell and capture its output. On Windows a .cmd
|
|
110
|
+
* shim resolves to the real .exe behind it, with a fallback to an explicitly
|
|
111
|
+
* quoted cmd.exe line guarded by the character allowlist. Callers must still
|
|
112
|
+
* validate every argument — this stops shell metacharacters, not bad flags.
|
|
113
|
+
* @param {string} cmd
|
|
114
|
+
* @param {string[]} args
|
|
115
|
+
* @param {{cwd?: string, timeout?: number}} [opts]
|
|
116
|
+
* @returns {Promise<{stdout: string, stderr: string}>}
|
|
117
|
+
*/
|
|
118
|
+
function execNoShell(cmd, args, opts = {}) {
|
|
119
|
+
return new Promise((resolve, reject) => {
|
|
120
|
+
const resolved = whichSync(cmd);
|
|
121
|
+
if (!resolved) return reject(fail(`Command not found on PATH: ${cmd}`, 500));
|
|
122
|
+
|
|
123
|
+
let file = resolved;
|
|
124
|
+
let argv = args;
|
|
125
|
+
const spawnOpts = {
|
|
126
|
+
cwd: opts.cwd || undefined,
|
|
127
|
+
env: opts.env,
|
|
128
|
+
timeout: opts.timeout,
|
|
129
|
+
windowsHide: true,
|
|
130
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
const ext = path.extname(resolved).toLowerCase();
|
|
134
|
+
if (IS_WIN && (ext === '.cmd' || ext === '.bat')) {
|
|
135
|
+
const exe = exeBehindShim(resolved);
|
|
136
|
+
if (exe) {
|
|
137
|
+
file = exe;
|
|
138
|
+
} else {
|
|
139
|
+
for (const arg of [resolved, ...args]) {
|
|
140
|
+
if (WIN_CMD_FORBIDDEN.test(String(arg))) {
|
|
141
|
+
return reject(fail('Argument contains a character that cannot be passed safely', 400));
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
const line = [resolved, ...args].map((a) => `"${a}"`).join(' ');
|
|
145
|
+
file = process.env.ComSpec || 'cmd.exe';
|
|
146
|
+
argv = ['/d', '/s', '/c', `"${line}"`];
|
|
147
|
+
spawnOpts.windowsVerbatimArguments = true;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
let child;
|
|
152
|
+
try {
|
|
153
|
+
child = spawn(file, argv, spawnOpts);
|
|
154
|
+
} catch (err) {
|
|
155
|
+
return reject(err);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
let stdout = '';
|
|
159
|
+
let stderr = '';
|
|
160
|
+
child.stdout.on('data', (d) => { stdout += d; });
|
|
161
|
+
child.stderr.on('data', (d) => { stderr += d; });
|
|
162
|
+
child.on('error', reject);
|
|
163
|
+
child.on('close', (code, signal) => {
|
|
164
|
+
if (signal) return reject(new Error(`Command terminated by ${signal}: ${stderr.trim()}`));
|
|
165
|
+
// The claude CLI reports its own failures on stdout, so stderr alone is
|
|
166
|
+
// often empty and the caller would surface only the exit code.
|
|
167
|
+
if (code !== 0) return reject(new Error(stderr.trim() || stdout.trim() || `Command exited with code ${code}`));
|
|
168
|
+
resolve({ stdout, stderr });
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
module.exports = { assertOpenTarget, execNoShell, whichSync, exeBehindShim };
|
package/lib/panes.js
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
// Per-session pane layout: the tabs next to Board. The file is the only copy, shared by every
|
|
2
|
+
// board process on the config dir, the CLI and the agent. Which tab is on screen is each
|
|
3
|
+
// browser tab's own state, not stored here. Expiry: docs/retention.md.
|
|
4
|
+
|
|
5
|
+
const path = require('node:path');
|
|
6
|
+
const { httpError } = require('./http-error');
|
|
7
|
+
const { canonicalPath } = require('./linked-docs');
|
|
8
|
+
const { isLoopbackAddress } = require('./net-guard');
|
|
9
|
+
const { oneLine } = require('./one-line');
|
|
10
|
+
const { pruneMap } = require('./retention');
|
|
11
|
+
|
|
12
|
+
const MAX_PANES = 50;
|
|
13
|
+
const MAX_TITLE = 100;
|
|
14
|
+
const FILE_KINDS = new Set(['html', 'markdown', 'text', 'image']);
|
|
15
|
+
const KINDS = new Set([...FILE_KINDS, 'url', 'message']);
|
|
16
|
+
const MAX_MESSAGE_TARGET = 300;
|
|
17
|
+
const isFileKind = (kind) => FILE_KINDS.has(kind);
|
|
18
|
+
|
|
19
|
+
const portOf = (u) => Number(u.port || (u.protocol === 'https:' ? 443 : 80));
|
|
20
|
+
|
|
21
|
+
// A frame with allow-scripts plus allow-same-origin on the board's origin can reach
|
|
22
|
+
// into its parent and drop its own sandbox. Every name the board is served under counts.
|
|
23
|
+
function isOwnOrigin(url, { boardPort, boardHosts = [] }) {
|
|
24
|
+
let u;
|
|
25
|
+
try {
|
|
26
|
+
u = new URL(url);
|
|
27
|
+
} catch {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
const loopback = isLoopbackAddress(u.hostname);
|
|
31
|
+
return u.protocol === 'http:' && portOf(u) === boardPort && (loopback || boardHosts.includes(u.hostname));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const emptyLayout = () => ({ rev: 0, nextId: 1, panes: [], updatedAt: null });
|
|
35
|
+
|
|
36
|
+
const targetKey = (kind, target) => (isFileKind(kind) ? `file:${canonicalPath(target)}` : `${kind}:${target}`);
|
|
37
|
+
const paneKey = (p) => (p.companion ? `companion:${p.companion}` : targetKey(p.kind, p.target));
|
|
38
|
+
|
|
39
|
+
function defaultTitle(kind, target) {
|
|
40
|
+
if (kind === 'message') return 'Message';
|
|
41
|
+
if (kind !== 'url') return path.basename(target);
|
|
42
|
+
try {
|
|
43
|
+
return new URL(target).host;
|
|
44
|
+
} catch {
|
|
45
|
+
return target;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const paneTitle = (kind, target, title) => oneLine(title, MAX_TITLE) || oneLine(defaultTitle(kind, target), MAX_TITLE);
|
|
50
|
+
|
|
51
|
+
const idNumber = (id) => Number(id.slice(1)) || 0;
|
|
52
|
+
|
|
53
|
+
function cleanLayout(raw) {
|
|
54
|
+
if (!raw || !Array.isArray(raw.panes) || !Number.isFinite(raw.updatedAt)) return null;
|
|
55
|
+
const ids = new Set();
|
|
56
|
+
const panes = raw.panes.filter((p) => {
|
|
57
|
+
const ok = p && typeof p.id === 'string' && !ids.has(p.id) && KINDS.has(p.kind) && typeof p.target === 'string' && p.target;
|
|
58
|
+
if (ok) ids.add(p.id);
|
|
59
|
+
return ok;
|
|
60
|
+
});
|
|
61
|
+
const nextId = Math.max(Number.isInteger(raw.nextId) ? raw.nextId : 1, ...panes.map((p) => idNumber(p.id) + 1));
|
|
62
|
+
const rev = Number.isInteger(raw.rev) && raw.rev > 0 ? raw.rev : 1;
|
|
63
|
+
return { rev, nextId, panes: panes.slice(0, MAX_PANES), updatedAt: raw.updatedAt };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const publicLayout = (l) => ({ rev: l.rev, panes: l.panes.map((p) => ({ ...p })), updatedAt: l.updatedAt });
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Every write re-reads the file first, so two boards on one config dir add to each other's
|
|
70
|
+
* layouts instead of overwriting them. The read-to-rename window is left open: both writers
|
|
71
|
+
* are one user's own board tabs and CLI calls.
|
|
72
|
+
*
|
|
73
|
+
* @param {object} o
|
|
74
|
+
* @param {() => object|null|undefined} o.load returns `{version: 2, sessions: {[id]: {rev, nextId, panes, updatedAt}}}`,
|
|
75
|
+
* null when there is no file, or undefined when the file has not changed since the last load or save
|
|
76
|
+
* @param {(data: object) => void} o.save
|
|
77
|
+
* @param {(sessionId: string, layout: object) => void} [o.onChange] called for each layout whose rev
|
|
78
|
+
* moved, from this store's writes and from `reload()` alike
|
|
79
|
+
*/
|
|
80
|
+
function createPaneStore({ load, save, onChange = () => {}, now = Date.now }) {
|
|
81
|
+
let bySession = new Map();
|
|
82
|
+
|
|
83
|
+
function reload() {
|
|
84
|
+
const data = load();
|
|
85
|
+
if (data === undefined) return;
|
|
86
|
+
const next = new Map();
|
|
87
|
+
const saved = data?.sessions;
|
|
88
|
+
if (saved && typeof saved === 'object') {
|
|
89
|
+
for (const [id, raw] of Object.entries(saved)) {
|
|
90
|
+
const layout = cleanLayout(raw);
|
|
91
|
+
if (layout) next.set(id, layout);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
const before = bySession;
|
|
95
|
+
bySession = next;
|
|
96
|
+
for (const [id, layout] of next) {
|
|
97
|
+
if (before.get(id)?.rev !== layout.rev) onChange(id, publicLayout(layout));
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
reload();
|
|
101
|
+
|
|
102
|
+
const persist = () => save({ version: 2, sessions: Object.fromEntries(bySession) });
|
|
103
|
+
|
|
104
|
+
function write(sessionId, layout) {
|
|
105
|
+
layout.rev += 1;
|
|
106
|
+
layout.updatedAt = now();
|
|
107
|
+
bySession.set(sessionId, layout);
|
|
108
|
+
persist();
|
|
109
|
+
onChange(sessionId, publicLayout(layout));
|
|
110
|
+
return publicLayout(layout);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function get(sessionId) {
|
|
114
|
+
const layout = bySession.get(sessionId);
|
|
115
|
+
return publicLayout(layout || emptyLayout());
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// The same target added again returns its pane where it is, so a repeated `pane add` is harmless.
|
|
119
|
+
// `show` marks a show card's file, which the board renders with the card's theme. `companion`
|
|
120
|
+
// names the board-hosted tool a url pane shows, which the board starts again when its port is gone;
|
|
121
|
+
// a session has one pane per companion, whatever port its URL names.
|
|
122
|
+
function add(sessionId, { kind, target, title, show, companion }) {
|
|
123
|
+
if (!KINDS.has(kind)) throw httpError(400, `Unknown pane kind: ${kind}`, 'bad_kind');
|
|
124
|
+
if (kind === 'message' && target.length > MAX_MESSAGE_TARGET) {
|
|
125
|
+
throw httpError(400, `A message target holds at most ${MAX_MESSAGE_TARGET} characters`, 'bad_target');
|
|
126
|
+
}
|
|
127
|
+
reload();
|
|
128
|
+
const layout = bySession.get(sessionId) || emptyLayout();
|
|
129
|
+
const tool = kind === 'url' && companion;
|
|
130
|
+
const key = paneKey({ kind, target, companion: tool });
|
|
131
|
+
const existing = layout.panes.find((p) => paneKey(p) === key);
|
|
132
|
+
if (existing) return { pane: { ...existing }, added: false, layout: publicLayout(layout) };
|
|
133
|
+
if (layout.panes.length >= MAX_PANES) throw httpError(409, `A session holds at most ${MAX_PANES} panes`, 'pane_limit');
|
|
134
|
+
const pane = {
|
|
135
|
+
id: `p${layout.nextId++}`,
|
|
136
|
+
kind,
|
|
137
|
+
target,
|
|
138
|
+
title: paneTitle(kind, target, title),
|
|
139
|
+
...(show && { show: true }),
|
|
140
|
+
...(tool && { companion }),
|
|
141
|
+
addedAt: now(),
|
|
142
|
+
};
|
|
143
|
+
layout.panes.push(pane);
|
|
144
|
+
return { pane: { ...pane }, added: true, layout: write(sessionId, layout) };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Returns the new layout, or null when the session has no such pane. An emptied layout stays
|
|
148
|
+
// until retention drops it, so its ids and rev keep counting up.
|
|
149
|
+
function remove(sessionId, paneId) {
|
|
150
|
+
reload();
|
|
151
|
+
const layout = bySession.get(sessionId);
|
|
152
|
+
const i = layout ? layout.panes.findIndex((p) => p.id === paneId) : -1;
|
|
153
|
+
if (i < 0) return null;
|
|
154
|
+
layout.panes.splice(i, 1);
|
|
155
|
+
return write(sessionId, layout);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Drops the show panes of these files; returns the new layout, or null when none matched.
|
|
159
|
+
function removeShow(sessionId, files) {
|
|
160
|
+
reload();
|
|
161
|
+
const layout = bySession.get(sessionId);
|
|
162
|
+
if (!layout) return null;
|
|
163
|
+
const keys = new Set(files.map((f) => targetKey('html', f)));
|
|
164
|
+
const kept = layout.panes.filter((p) => !(p.show && keys.has(targetKey(p.kind, p.target))));
|
|
165
|
+
if (kept.length === layout.panes.length) return null;
|
|
166
|
+
layout.panes = kept;
|
|
167
|
+
return write(sessionId, layout);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// An empty title gives the pane its default title back. Returns null when the session has no such pane.
|
|
171
|
+
function rename(sessionId, paneId, title) {
|
|
172
|
+
reload();
|
|
173
|
+
const layout = bySession.get(sessionId);
|
|
174
|
+
const pane = layout?.panes.find((p) => p.id === paneId);
|
|
175
|
+
if (!pane) return null;
|
|
176
|
+
const next = paneTitle(pane.kind, pane.target, title);
|
|
177
|
+
if (next === pane.title) return get(sessionId);
|
|
178
|
+
pane.title = next;
|
|
179
|
+
return write(sessionId, layout);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// `order` is every pane id of the session, in the new tab order.
|
|
183
|
+
function reorder(sessionId, order) {
|
|
184
|
+
reload();
|
|
185
|
+
const layout = bySession.get(sessionId);
|
|
186
|
+
const ids = layout ? layout.panes.map((p) => p.id) : [];
|
|
187
|
+
const valid = Array.isArray(order) && order.length === ids.length && new Set(order).size === ids.length;
|
|
188
|
+
if (!valid || !order.every((id) => ids.includes(id))) throw httpError(400, 'order must list every pane id once', 'bad_order');
|
|
189
|
+
if (!layout || order.every((id, i) => id === ids[i])) return get(sessionId);
|
|
190
|
+
layout.panes = order.map((id) => layout.panes.find((p) => p.id === id));
|
|
191
|
+
return write(sessionId, layout);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Returns how many layouts went; writes only when one did.
|
|
195
|
+
function prune({ known, maxAgeMs }) {
|
|
196
|
+
reload();
|
|
197
|
+
const removed = pruneMap(bySession, (l) => l.updatedAt, { known, maxAgeMs, now: now() });
|
|
198
|
+
if (removed) persist();
|
|
199
|
+
return removed;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function fileTargets() {
|
|
203
|
+
const out = new Set();
|
|
204
|
+
for (const layout of bySession.values()) {
|
|
205
|
+
for (const p of layout.panes) if (isFileKind(p.kind)) out.add(p.target);
|
|
206
|
+
}
|
|
207
|
+
return out;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return { get, add, remove, removeShow, rename, reorder, prune, reload, fileTargets };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
module.exports = { createPaneStore, isOwnOrigin, portOf, MAX_PANES, MAX_TITLE };
|