getbotons 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +505 -2
- package/installer/admin.mjs +6 -0
- package/installer/botons.mjs +430 -0
- package/installer/brain-clis.mjs +28 -0
- package/installer/linger.mjs +39 -0
- package/installer/output.mjs +27 -0
- package/installer/revision.mjs +30 -0
- package/installer/shell-path.mjs +66 -0
- package/installer/source-lock.json +2891 -0
- package/package.json +26 -4
- package/payload/LICENSE +21 -0
- package/payload/SECURITY.md +33 -0
- package/payload/installer/admin.mjs +6 -0
- package/payload/installer/botons.mjs +430 -0
- package/payload/installer/brain-clis.mjs +28 -0
- package/payload/installer/linger.mjs +39 -0
- package/payload/installer/output.mjs +27 -0
- package/payload/installer/revision.mjs +30 -0
- package/payload/installer/shell-path.mjs +66 -0
- package/payload/installer/source-lock.json +2891 -0
- package/payload/npm-shrinkwrap.json +51 -0
- package/payload/package.json +13 -0
- package/payload/scripts/backup.ts +47 -0
- package/payload/scripts/botons-backup.service +15 -0
- package/payload/scripts/botons-backup.timer +11 -0
- package/payload/scripts/botons.service +36 -0
- package/payload/scripts/check-browser.ts +90 -0
- package/payload/scripts/doctor.ts +790 -0
- package/payload/scripts/install-backup-timer.ts +52 -0
- package/payload/scripts/install-service.ts +206 -0
- package/payload/scripts/login.ts +17 -0
- package/payload/scripts/migrate-names.ts +47 -0
- package/payload/scripts/pair.ts +163 -0
- package/payload/scripts/restore.ts +108 -0
- package/payload/server/bin/botons +4 -0
- package/payload/server/bin/legacy/minion +4 -0
- package/payload/server/bin/legacy/tots +4 -0
- package/payload/server/src/acting.ts +16 -0
- package/payload/server/src/api-keys.ts +544 -0
- package/payload/server/src/api.ts +1484 -0
- package/payload/server/src/app.ts +546 -0
- package/payload/server/src/approvals.ts +873 -0
- package/payload/server/src/ask-rules.ts +181 -0
- package/payload/server/src/audit.ts +158 -0
- package/payload/server/src/auth.ts +497 -0
- package/payload/server/src/backup.ts +382 -0
- package/payload/server/src/brain-login.ts +75 -0
- package/payload/server/src/browser-cert.ts +67 -0
- package/payload/server/src/browser-handoff.ts +119 -0
- package/payload/server/src/browser-handover.ts +175 -0
- package/payload/server/src/browser-policy.ts +210 -0
- package/payload/server/src/browser-proxy.ts +580 -0
- package/payload/server/src/browser-request.ts +335 -0
- package/payload/server/src/browser-screenshot.ts +56 -0
- package/payload/server/src/browser-send.ts +155 -0
- package/payload/server/src/browser.ts +2164 -0
- package/payload/server/src/callback.ts +218 -0
- package/payload/server/src/capabilities.ts +90 -0
- package/payload/server/src/cards.ts +243 -0
- package/payload/server/src/cli/approve-mcp.ts +119 -0
- package/payload/server/src/cli/botons.ts +629 -0
- package/payload/server/src/cli/browser-mcp.ts +168 -0
- package/payload/server/src/cli/workspace-file.ts +78 -0
- package/payload/server/src/cli-homes.ts +38 -0
- package/payload/server/src/cli-install.ts +104 -0
- package/payload/server/src/cli-updates.ts +112 -0
- package/payload/server/src/codex-config-repair.ts +80 -0
- package/payload/server/src/config.ts +473 -0
- package/payload/server/src/connector-access.ts +158 -0
- package/payload/server/src/connector-launch.ts +203 -0
- package/payload/server/src/connectors.ts +947 -0
- package/payload/server/src/disk-usage.ts +62 -0
- package/payload/server/src/engine.ts +2328 -0
- package/payload/server/src/env-init.ts +5 -0
- package/payload/server/src/events.ts +46 -0
- package/payload/server/src/files.ts +528 -0
- package/payload/server/src/github-access.ts +52 -0
- package/payload/server/src/health.ts +117 -0
- package/payload/server/src/held-dir.ts +96 -0
- package/payload/server/src/hooks.ts +141 -0
- package/payload/server/src/http.ts +207 -0
- package/payload/server/src/index.ts +139 -0
- package/payload/server/src/install-type.ts +11 -0
- package/payload/server/src/instructions.ts +204 -0
- package/payload/server/src/isolation.ts +234 -0
- package/payload/server/src/legacy-upgrade.ts +668 -0
- package/payload/server/src/limits.ts +44 -0
- package/payload/server/src/lock.ts +75 -0
- package/payload/server/src/log.ts +137 -0
- package/payload/server/src/managed-clis.ts +65 -0
- package/payload/server/src/mcp-client.ts +229 -0
- package/payload/server/src/notify.ts +193 -0
- package/payload/server/src/oauth-connections.ts +650 -0
- package/payload/server/src/oauth.ts +311 -0
- package/payload/server/src/outbound.ts +142 -0
- package/payload/server/src/portable.ts +1669 -0
- package/payload/server/src/prompt.ts +676 -0
- package/payload/server/src/providers/api-brain.ts +612 -0
- package/payload/server/src/providers/api-history.ts +131 -0
- package/payload/server/src/providers/api-tools.ts +481 -0
- package/payload/server/src/providers/api-wire.ts +690 -0
- package/payload/server/src/providers/claude-cli.ts +620 -0
- package/payload/server/src/providers/cli-check.ts +265 -0
- package/payload/server/src/providers/cli-turn.ts +20 -0
- package/payload/server/src/providers/codex-cli.ts +552 -0
- package/payload/server/src/providers/codex-workspace.ts +61 -0
- package/payload/server/src/providers/failures.ts +143 -0
- package/payload/server/src/providers/index.ts +131 -0
- package/payload/server/src/providers/mcp-stdio.ts +147 -0
- package/payload/server/src/providers/mock.ts +94 -0
- package/payload/server/src/providers/process.ts +268 -0
- package/payload/server/src/providers/readiness.ts +43 -0
- package/payload/server/src/providers/sse.ts +73 -0
- package/payload/server/src/providers/types.ts +173 -0
- package/payload/server/src/rates.ts +128 -0
- package/payload/server/src/reactions.ts +266 -0
- package/payload/server/src/recent-logs.ts +489 -0
- package/payload/server/src/rehearsal.ts +593 -0
- package/payload/server/src/rehearsals.ts +303 -0
- package/payload/server/src/replay.ts +165 -0
- package/payload/server/src/sandbox.ts +767 -0
- package/payload/server/src/schedule.ts +214 -0
- package/payload/server/src/scheduler.ts +24 -0
- package/payload/server/src/schema.ts +882 -0
- package/payload/server/src/secrets.ts +46 -0
- package/payload/server/src/setup.ts +356 -0
- package/payload/server/src/skill-packages.ts +363 -0
- package/payload/server/src/skills.ts +527 -0
- package/payload/server/src/spend.ts +255 -0
- package/payload/server/src/ssrf.ts +126 -0
- package/payload/server/src/starter-gate.ts +168 -0
- package/payload/server/src/starters.ts +610 -0
- package/payload/server/src/storage.ts +32 -0
- package/payload/server/src/store.ts +3087 -0
- package/payload/server/src/tailnet.ts +132 -0
- package/payload/server/src/templates.ts +454 -0
- package/payload/server/src/trash.ts +772 -0
- package/payload/server/src/util.ts +97 -0
- package/payload/server/src/webhooks.ts +216 -0
- package/payload/server/src/work-feed.ts +473 -0
- package/payload/shared/app-version.d.mts +1 -0
- package/payload/shared/app-version.mjs +3 -0
- package/payload/shared/boton.ts +323 -0
- package/payload/shared/brand.ts +107 -0
- package/payload/shared/cards.ts +427 -0
- package/payload/shared/effort.ts +91 -0
- package/payload/shared/files.ts +104 -0
- package/payload/shared/handoff.ts +97 -0
- package/payload/shared/install-commands.ts +7 -0
- package/payload/shared/legacy-names.d.mts +35 -0
- package/payload/shared/legacy-names.mjs +244 -0
- package/payload/shared/live-view.ts +94 -0
- package/payload/shared/look-critter.ts +189 -0
- package/payload/shared/look-tater.ts +127 -0
- package/payload/shared/look.ts +200 -0
- package/payload/shared/reactions.ts +43 -0
- package/payload/shared/seeded.ts +34 -0
- package/payload/shared/services.ts +90 -0
- package/payload/shared/types.ts +3016 -0
- package/payload/shared/util.ts +68 -0
- package/payload/shared/versions.ts +44 -0
- package/payload/shared/wake.ts +164 -0
- package/payload/web/dist/apple-touch-icon.png +0 -0
- package/payload/web/dist/assets/Icons-nytVAa9g.js +1 -0
- package/payload/web/dist/assets/NewBotDialog-DXr2XhDP.js +1 -0
- package/payload/web/dist/assets/PairScreen-CMcJO7cQ.js +1 -0
- package/payload/web/dist/assets/Panel-Bvqz3I3m.js +4 -0
- package/payload/web/dist/assets/ReactionPicker-B0SKsjiy.js +1 -0
- package/payload/web/dist/assets/SettingRows-CvLomtuo.js +4 -0
- package/payload/web/dist/assets/SettingsDialog-CQ41LV-P.js +8 -0
- package/payload/web/dist/assets/Setup-C-3P7Mtn.js +1 -0
- package/payload/web/dist/assets/Skills-DXMH69kH.js +1 -0
- package/payload/web/dist/assets/StarterSetup-Ce2V1_Cs.js +5 -0
- package/payload/web/dist/assets/Steps-CKtB3GAM.js +1 -0
- package/payload/web/dist/assets/emoji-catalog-DfpPz8qw.js +540 -0
- package/payload/web/dist/assets/index-BEU7FDxV.css +1 -0
- package/payload/web/dist/assets/index-Dhwn-rf0.js +3 -0
- package/payload/web/dist/assets/react-C1iE8mnY.js +9 -0
- package/payload/web/dist/assets/reactions-rw0YeCmA.js +1 -0
- package/payload/web/dist/assets/rolldown-runtime-XKyaHtl9.js +1 -0
- package/payload/web/dist/assets/util-Cx5feSJu.js +1 -0
- package/payload/web/dist/assets/vendor-B3qdcjGo.js +14 -0
- package/payload/web/dist/favicon.svg +1 -0
- package/payload/web/dist/icon-192.png +0 -0
- package/payload/web/dist/icon-512.png +0 -0
- package/payload/web/dist/icon.svg +1 -0
- package/payload/web/dist/index.html +67 -0
- package/payload/web/dist/manifest.webmanifest +12 -0
- package/shared/legacy-names.d.mts +35 -0
- package/shared/legacy-names.mjs +244 -0
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// Imported first by entry points, before any module reads process.env: a setting given under a deprecated name is
|
|
2
|
+
// read under its BOTONS_* or BOTON_* name from then on (see shared/legacy-names.mjs).
|
|
3
|
+
import { applyLegacyEnv } from '../../shared/legacy-names.mjs';
|
|
4
|
+
|
|
5
|
+
export const startupEnvNotices = applyLegacyEnv(process.env);
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { ServerEvent } from '../../shared/types.ts';
|
|
2
|
+
import { log } from './log.ts';
|
|
3
|
+
|
|
4
|
+
type Listener = (event: ServerEvent) => void;
|
|
5
|
+
|
|
6
|
+
/** In-process pub/sub that fans server events out to SSE clients and internal waiters. */
|
|
7
|
+
export class EventHub {
|
|
8
|
+
#listeners = new Set<Listener>();
|
|
9
|
+
#streams = new Set<() => void>();
|
|
10
|
+
|
|
11
|
+
publish(event: ServerEvent): void {
|
|
12
|
+
for (const l of this.#listeners) {
|
|
13
|
+
try {
|
|
14
|
+
l(event);
|
|
15
|
+
} catch (err) {
|
|
16
|
+
log.error('event listener failed', { event: event.type, err });
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
get listenerCount(): number {
|
|
22
|
+
return this.#listeners.size;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
addStream(end: () => void): () => void {
|
|
26
|
+
this.#streams.add(end);
|
|
27
|
+
return () => this.#streams.delete(end);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** At shutdown. Ending (not destroying) a response still sends what was written to it, such as a turn's last events. */
|
|
31
|
+
endStreams(): void {
|
|
32
|
+
for (const end of [...this.#streams]) {
|
|
33
|
+
try {
|
|
34
|
+
end();
|
|
35
|
+
} catch (err) {
|
|
36
|
+
log.debug('could not end an event stream', { err });
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
this.#streams.clear();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
subscribe(l: Listener): () => void {
|
|
43
|
+
this.#listeners.add(l);
|
|
44
|
+
return () => this.#listeners.delete(l);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,528 @@
|
|
|
1
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
2
|
+
import { closeSync, constants, type Stats } from 'node:fs';
|
|
3
|
+
import { type FileHandle, link, lstat, mkdir, open, readdir, readlink, realpath, stat, unlink } from 'node:fs/promises';
|
|
4
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
5
|
+
import { isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
6
|
+
import { pipeline } from 'node:stream/promises';
|
|
7
|
+
import { ATTACHMENTS_MAX, fileKind, mimeType, PREVIEW_MAX_BYTES, UPLOAD_MAX_BYTES } from '../../shared/files.ts';
|
|
8
|
+
import type { FileEntry, FileListing, FileRef } from '../../shared/types.ts';
|
|
9
|
+
import { heldLookups, heldPath, holdBeneath, holdDir } from './held-dir.ts';
|
|
10
|
+
import { HttpError } from './store.ts';
|
|
11
|
+
import { inside } from './util.ts';
|
|
12
|
+
|
|
13
|
+
// A Boton writes these files, so nothing is served in a way that runs in the app's origin.
|
|
14
|
+
|
|
15
|
+
const INBOX = 'inbox';
|
|
16
|
+
const LIST_MAX = 1000;
|
|
17
|
+
/** Past this many entries, only the first ones read are sorted. */
|
|
18
|
+
const LIST_SCAN_MAX = 5000;
|
|
19
|
+
|
|
20
|
+
const OUTSIDE = 'That path is outside the workspace';
|
|
21
|
+
|
|
22
|
+
/** Like GitHub's raw files: no script, no plugins, no resources but inline styles (image and text viewers use them). */
|
|
23
|
+
const SANDBOX_CSP = "default-src 'none'; style-src 'unsafe-inline'; sandbox";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* An HTML file's preview page. Its script runs, but `sandbox` without `allow-same-origin` puts it in an
|
|
27
|
+
* opaque origin: it can't read the app's storage (where the device token lives) or cookies, or script the
|
|
28
|
+
* app's pages. Never add `allow-same-origin`, `allow-top-navigation`, `allow-popups` or `allow-forms`.
|
|
29
|
+
* Nothing loads from the network (inline and data: only), `connect-src 'none'` stops fetch, XHR,
|
|
30
|
+
* WebSocket and EventSource (so not even /api), `form-action 'none'` stops posting a form, and only
|
|
31
|
+
* the app may frame it. It is not network isolation: no CSP stops the page loading another address in
|
|
32
|
+
* its own frame or tab, or using WebRTC, so its script can still send out what it holds (the file, and
|
|
33
|
+
* this file's preview key). The viewer closes a frame that navigates (`PreviewFrame` in Files.tsx).
|
|
34
|
+
*/
|
|
35
|
+
export const PREVIEW_CSP = [
|
|
36
|
+
'sandbox allow-scripts',
|
|
37
|
+
"default-src 'none'",
|
|
38
|
+
"script-src 'unsafe-inline' 'unsafe-eval'",
|
|
39
|
+
"style-src 'unsafe-inline'",
|
|
40
|
+
'img-src data: blob:',
|
|
41
|
+
'font-src data:',
|
|
42
|
+
'media-src data: blob:',
|
|
43
|
+
"connect-src 'none'",
|
|
44
|
+
"form-action 'none'",
|
|
45
|
+
"frame-src 'none'",
|
|
46
|
+
"worker-src 'none'",
|
|
47
|
+
"manifest-src 'none'",
|
|
48
|
+
"base-uri 'none'",
|
|
49
|
+
"frame-ancestors 'self'",
|
|
50
|
+
].join('; ');
|
|
51
|
+
|
|
52
|
+
/** Powerful features the preview page never gets, even if a browser would ask. */
|
|
53
|
+
const PREVIEW_PERMISSIONS = 'camera=(), microphone=(), geolocation=(), display-capture=(), usb=(), serial=(), hid=(), bluetooth=(), payment=(), clipboard-read=(), publickey-credentials-get=()';
|
|
54
|
+
|
|
55
|
+
/** Tells the app's viewer that its preview frame didn't get the page: only a status, nothing of the file. */
|
|
56
|
+
const PREVIEW_ERROR_SCRIPT = "if (parent !== window) parent.postMessage({ botonsPreview: 'failed', status: Number(document.documentElement.dataset.status) }, '*');";
|
|
57
|
+
const PREVIEW_ERROR_STYLE = 'body{font:15px/1.5 system-ui,sans-serif;max-width:32rem;margin:10vh auto;padding:0 1.25rem;color:#1d1d1f}';
|
|
58
|
+
const cspHash = (s: string) => `'sha256-${createHash('sha256').update(s).digest('base64')}'`;
|
|
59
|
+
/** The error page's own sandbox: just its one script and style, nothing else. */
|
|
60
|
+
export const PREVIEW_ERROR_CSP = [
|
|
61
|
+
'sandbox allow-scripts',
|
|
62
|
+
"default-src 'none'",
|
|
63
|
+
`script-src ${cspHash(PREVIEW_ERROR_SCRIPT)}`,
|
|
64
|
+
`style-src ${cspHash(PREVIEW_ERROR_STYLE)}`,
|
|
65
|
+
"form-action 'none'",
|
|
66
|
+
"base-uri 'none'",
|
|
67
|
+
"frame-ancestors 'self'",
|
|
68
|
+
].join('; ');
|
|
69
|
+
|
|
70
|
+
const escapeHtml = (s: string) => s.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Instead of a JSON error in the preview frame (a key that expired, a file deleted, swapped or grown past
|
|
74
|
+
* the limit after the link was made): a short page that says why, and posts its status to the app so the
|
|
75
|
+
* viewer shows its no-preview card. It never holds the file, the key or anything of the app.
|
|
76
|
+
*/
|
|
77
|
+
export function sendPreviewError(res: ServerResponse, err: HttpError): void {
|
|
78
|
+
const body = `<!doctype html><html lang="en" data-status="${err.status}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>No preview</title><style>${PREVIEW_ERROR_STYLE}</style></head><body><h1>No preview</h1><p>${escapeHtml(err.message)}</p><script>${PREVIEW_ERROR_SCRIPT}</script></body></html>`;
|
|
79
|
+
res.writeHead(err.status, {
|
|
80
|
+
'content-type': 'text/html; charset=utf-8',
|
|
81
|
+
'content-length': String(Buffer.byteLength(body)),
|
|
82
|
+
'content-security-policy': PREVIEW_ERROR_CSP,
|
|
83
|
+
'permissions-policy': PREVIEW_PERMISSIONS,
|
|
84
|
+
'x-content-type-options': 'nosniff',
|
|
85
|
+
'cross-origin-resource-policy': 'same-origin',
|
|
86
|
+
'referrer-policy': 'no-referrer',
|
|
87
|
+
'cache-control': 'private, no-store',
|
|
88
|
+
});
|
|
89
|
+
res.end(body);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* How a file is fetched: `raw` (an <img>, a link, the text viewer), `download`, or `preview`, an HTML
|
|
94
|
+
* file's sandboxed page, reached only with a key for that one file (`Auth.previewKey`).
|
|
95
|
+
*/
|
|
96
|
+
export type Serving = 'raw' | 'download' | 'preview';
|
|
97
|
+
|
|
98
|
+
function errCode(err: unknown): string | undefined {
|
|
99
|
+
return (err as NodeJS.ErrnoException)?.code;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function fsError(err: unknown): never {
|
|
103
|
+
const code = errCode(err);
|
|
104
|
+
if (code === 'ENOENT' || code === 'ENOTDIR') throw new HttpError(404, 'No such file or folder in the workspace');
|
|
105
|
+
if (code === 'EACCES' || code === 'EPERM' || code === 'ELOOP') throw new HttpError(403, `Can't read that (${code})`);
|
|
106
|
+
throw err;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** The workspace as the filesystem sees it, created if it is missing, as a turn would. */
|
|
110
|
+
async function workspaceRoot(workspace: string): Promise<string> {
|
|
111
|
+
await mkdir(workspace, { recursive: true });
|
|
112
|
+
return realpath(workspace);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
interface Resolved {
|
|
116
|
+
root: string;
|
|
117
|
+
abs: string;
|
|
118
|
+
/** `abs` relative to `root`, "/"-separated. */
|
|
119
|
+
rel: string;
|
|
120
|
+
stat: Stats;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* The file or folder a client's path names inside the workspace, symlinks resolved: 403 if it leads
|
|
125
|
+
* outside. One outside on its face is refused before the disk is touched, so nothing out there is revealed.
|
|
126
|
+
*/
|
|
127
|
+
export async function resolveInWorkspace(workspace: string, input: unknown): Promise<Resolved> {
|
|
128
|
+
if (typeof input !== 'string') throw new HttpError(400, 'path must be a string');
|
|
129
|
+
if (input.includes('\0')) throw new HttpError(400, 'path is not valid');
|
|
130
|
+
const root = await workspaceRoot(workspace);
|
|
131
|
+
const ws = resolve(workspace);
|
|
132
|
+
const requested = isAbsolute(input) ? resolve(input) : resolve(root, input);
|
|
133
|
+
if (!inside(root, requested) && !inside(ws, requested)) throw new HttpError(403, OUTSIDE);
|
|
134
|
+
let abs: string;
|
|
135
|
+
try {
|
|
136
|
+
abs = await realpath(requested);
|
|
137
|
+
} catch (err) {
|
|
138
|
+
fsError(err);
|
|
139
|
+
}
|
|
140
|
+
if (!inside(root, abs)) throw new HttpError(403, OUTSIDE);
|
|
141
|
+
let st: Stats;
|
|
142
|
+
try {
|
|
143
|
+
st = await stat(abs);
|
|
144
|
+
} catch (err) {
|
|
145
|
+
fsError(err);
|
|
146
|
+
}
|
|
147
|
+
return { root, abs, rel: relative(root, abs).split(sep).join('/'), stat: st };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Open a resolved file and check that what was opened is still inside: a Boton could swap a folder on
|
|
152
|
+
* the way for a symlink after the check. Non-blocking, so a FIFO swapped in can't hold a server thread.
|
|
153
|
+
*/
|
|
154
|
+
async function openInside(root: string, abs: string): Promise<FileHandle> {
|
|
155
|
+
let fh: FileHandle;
|
|
156
|
+
try {
|
|
157
|
+
fh = await open(abs, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0));
|
|
158
|
+
} catch (err) {
|
|
159
|
+
fsError(err);
|
|
160
|
+
}
|
|
161
|
+
try {
|
|
162
|
+
// Linux says where the open file is; elsewhere the path must still lead to the same file.
|
|
163
|
+
const where = await readlink(`/proc/self/fd/${fh.fd}`).catch(() => null);
|
|
164
|
+
if (where !== null) {
|
|
165
|
+
if (!inside(root, where)) throw new HttpError(403, OUTSIDE);
|
|
166
|
+
} else {
|
|
167
|
+
const [opened, again] = await Promise.all([fh.stat(), realpath(abs).then(async (p) => (inside(root, p) ? stat(p) : null))]);
|
|
168
|
+
if (!again || again.dev !== opened.dev || again.ino !== opened.ino) throw new HttpError(403, OUTSIDE);
|
|
169
|
+
}
|
|
170
|
+
return fh;
|
|
171
|
+
} catch (err) {
|
|
172
|
+
await fh.close().catch(() => {});
|
|
173
|
+
throw err;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export async function listFolder(workspace: string, path: string): Promise<FileListing> {
|
|
178
|
+
const { root, abs, rel, stat: st } = await resolveInWorkspace(workspace, path || '.');
|
|
179
|
+
if (!st.isDirectory()) throw new HttpError(400, 'That is a file, not a folder');
|
|
180
|
+
let names: string[];
|
|
181
|
+
try {
|
|
182
|
+
names = (await readdir(abs)).slice(0, LIST_SCAN_MAX);
|
|
183
|
+
} catch (err) {
|
|
184
|
+
fsError(err);
|
|
185
|
+
}
|
|
186
|
+
const entries: FileEntry[] = [];
|
|
187
|
+
for (const name of names) {
|
|
188
|
+
const p = join(abs, name);
|
|
189
|
+
let s: Stats;
|
|
190
|
+
try {
|
|
191
|
+
s = await lstat(p);
|
|
192
|
+
if (s.isSymbolicLink()) {
|
|
193
|
+
// Shown only if it leads somewhere inside; then as what it leads to.
|
|
194
|
+
const target = await realpath(p);
|
|
195
|
+
if (!inside(root, target)) continue;
|
|
196
|
+
s = await stat(target);
|
|
197
|
+
}
|
|
198
|
+
} catch {
|
|
199
|
+
continue; // gone meanwhile, a broken link, or unreadable
|
|
200
|
+
}
|
|
201
|
+
if (!s.isFile() && !s.isDirectory()) continue;
|
|
202
|
+
entries.push({ name, path: rel ? `${rel}/${name}` : name, isDir: s.isDirectory(), size: s.isDirectory() ? 0 : s.size, mtime: s.mtime.toISOString() });
|
|
203
|
+
}
|
|
204
|
+
entries.sort((a, b) => b.mtime.localeCompare(a.mtime) || a.name.localeCompare(b.name));
|
|
205
|
+
return { path: rel, entries: entries.slice(0, LIST_MAX), truncated: entries.length > LIST_MAX || names.length >= LIST_SCAN_MAX };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export function disposition(kind: 'inline' | 'attachment', name: string): string {
|
|
209
|
+
const fallback = name.replace(/[^\x20-\x7e]|["\\%;]/g, '_');
|
|
210
|
+
const encoded = encodeURIComponent(name).replace(/['()*]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`);
|
|
211
|
+
return `${kind}; filename="${fallback}"; filename*=UTF-8''${encoded}`;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Raster images, SVG, PDFs and text are shown inline with a type fixed by the extension, SVG and text
|
|
216
|
+
* with no script; the rest downloads. HTML too: its only inline form is the preview page, because the
|
|
217
|
+
* raw URL carries the device's files key, which a page's script could read off its own URL.
|
|
218
|
+
*/
|
|
219
|
+
export function servingOf(name: string, size: number, mode: Serving): { type: string; inline: boolean; csp: string | null } {
|
|
220
|
+
const kind = fileKind(name);
|
|
221
|
+
if (mode === 'preview') {
|
|
222
|
+
if (kind !== 'html') throw new HttpError(400, 'Only HTML files have a preview page; fetch the file itself instead');
|
|
223
|
+
if (size > PREVIEW_MAX_BYTES) throw new HttpError(413, `Too big to preview (over ${Math.round(PREVIEW_MAX_BYTES / 1024 / 1024)} MB); download it instead`);
|
|
224
|
+
return { type: 'text/html; charset=utf-8', inline: true, csp: PREVIEW_CSP };
|
|
225
|
+
}
|
|
226
|
+
const inline = mode === 'raw' && kind !== 'other' && kind !== 'html' && size <= PREVIEW_MAX_BYTES;
|
|
227
|
+
if (!inline) return { type: 'application/octet-stream', inline: false, csp: SANDBOX_CSP };
|
|
228
|
+
if (kind === 'image') return { type: mimeType(name), inline: true, csp: SANDBOX_CSP };
|
|
229
|
+
// As a document, the sandbox stops its script; as an <img>, browsers never run it.
|
|
230
|
+
if (kind === 'svg') return { type: 'image/svg+xml', inline: true, csp: SANDBOX_CSP };
|
|
231
|
+
// Browsers won't render a PDF in a CSP sandbox, and a PDF can't script the page that shows it.
|
|
232
|
+
if (kind === 'pdf') return { type: 'application/pdf', inline: true, csp: null };
|
|
233
|
+
return { type: 'text/plain; charset=utf-8', inline: true, csp: SANDBOX_CSP };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export async function sendFile(req: IncomingMessage, res: ServerResponse, workspace: string, path: string, mode: Serving): Promise<void> {
|
|
237
|
+
const { root, abs, stat: st } = await resolveInWorkspace(workspace, path);
|
|
238
|
+
if (st.isDirectory()) throw new HttpError(400, 'That is a folder; list it with GET /api/bots/:id/files');
|
|
239
|
+
// Not a FIFO, socket or device: opening a FIFO would wait for a writer, tying up a server thread.
|
|
240
|
+
if (!st.isFile()) throw new HttpError(400, 'Not a regular file');
|
|
241
|
+
const fh = await openInside(root, abs);
|
|
242
|
+
try {
|
|
243
|
+
const opened = await fh.stat();
|
|
244
|
+
if (!opened.isFile()) throw new HttpError(400, 'Not a regular file');
|
|
245
|
+
const name = abs.slice(abs.lastIndexOf(sep) + 1);
|
|
246
|
+
const how = servingOf(name, opened.size, mode);
|
|
247
|
+
const etag = `W/"${opened.size.toString(16)}-${Math.floor(opened.mtimeMs).toString(16)}"`;
|
|
248
|
+
const headers: Record<string, string> = {
|
|
249
|
+
'content-type': how.type,
|
|
250
|
+
'content-disposition': disposition(how.inline ? 'inline' : 'attachment', name),
|
|
251
|
+
'x-content-type-options': 'nosniff',
|
|
252
|
+
'cross-origin-resource-policy': 'same-origin',
|
|
253
|
+
'referrer-policy': 'no-referrer',
|
|
254
|
+
'cache-control': 'private, no-cache',
|
|
255
|
+
etag,
|
|
256
|
+
'last-modified': opened.mtime.toUTCString(),
|
|
257
|
+
};
|
|
258
|
+
if (how.csp) headers['content-security-policy'] = how.csp;
|
|
259
|
+
if (mode === 'preview') {
|
|
260
|
+
// The key is in the URL: keep the page out of caches, and frame it nowhere but in the app.
|
|
261
|
+
headers['cache-control'] = 'private, no-store';
|
|
262
|
+
headers['permissions-policy'] = PREVIEW_PERMISSIONS;
|
|
263
|
+
delete headers.etag;
|
|
264
|
+
delete headers['last-modified'];
|
|
265
|
+
}
|
|
266
|
+
if (mode !== 'preview' && req.headers['if-none-match'] === etag) {
|
|
267
|
+
res.writeHead(304, headers).end();
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
res.writeHead(200, { ...headers, 'content-length': String(opened.size) });
|
|
271
|
+
try {
|
|
272
|
+
await pipeline(fh.createReadStream({ autoClose: false }), res);
|
|
273
|
+
} catch (err) {
|
|
274
|
+
// The caller went away mid-file: nothing to answer.
|
|
275
|
+
if (res.destroyed || errCode(err) === 'ERR_STREAM_PREMATURE_CLOSE') return;
|
|
276
|
+
throw err;
|
|
277
|
+
}
|
|
278
|
+
} finally {
|
|
279
|
+
await fh.close().catch(() => {});
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export function safeName(input: string): string {
|
|
284
|
+
const last = input.split(/[/\\]/).pop() ?? '';
|
|
285
|
+
let name = last
|
|
286
|
+
.normalize('NFC')
|
|
287
|
+
.replace(/[^\p{L}\p{N}._-]+/gu, '-')
|
|
288
|
+
.replace(/-{2,}/g, '-')
|
|
289
|
+
.replace(/-+\./g, '.')
|
|
290
|
+
.replace(/^[-.]+|[-.]+$/g, '');
|
|
291
|
+
if (!name) name = 'file';
|
|
292
|
+
if (name.length > 100) {
|
|
293
|
+
const dot = name.lastIndexOf('.');
|
|
294
|
+
const ext = dot > 0 && name.length - dot <= 12 ? name.slice(dot) : '';
|
|
295
|
+
name = name.slice(0, 100 - ext.length) + ext;
|
|
296
|
+
}
|
|
297
|
+
return name;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
async function inboxOf(root: string): Promise<string> {
|
|
301
|
+
const inbox = join(root, INBOX);
|
|
302
|
+
try {
|
|
303
|
+
await mkdir(inbox);
|
|
304
|
+
} catch (err) {
|
|
305
|
+
if (errCode(err) !== 'EEXIST') throw err;
|
|
306
|
+
}
|
|
307
|
+
const s = await lstat(inbox);
|
|
308
|
+
if (!s.isDirectory()) throw new HttpError(409, `${INBOX}/ in the workspace is not a folder`);
|
|
309
|
+
return inbox;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const PART = /^\.upload-[0-9a-f]{16}\.part$/;
|
|
313
|
+
/** A temp file untouched this long was left by a server that died mid-upload (one in progress is written to all along). */
|
|
314
|
+
const PART_STALE_MS = 60 * 60 * 1000;
|
|
315
|
+
|
|
316
|
+
/** Only a held directory path: lstat and unlink never follow the leaf. */
|
|
317
|
+
async function sweepParts(inbox: string): Promise<void> {
|
|
318
|
+
let names: string[];
|
|
319
|
+
try {
|
|
320
|
+
names = await readdir(inbox);
|
|
321
|
+
} catch {
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
const old = Date.now() - PART_STALE_MS;
|
|
325
|
+
for (const name of names) {
|
|
326
|
+
if (!PART.test(name)) continue;
|
|
327
|
+
const p = join(inbox, name);
|
|
328
|
+
try {
|
|
329
|
+
const s = await lstat(p);
|
|
330
|
+
if (s.isFile() && s.mtimeMs < old) await unlink(p);
|
|
331
|
+
} catch {}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function today(tz: string): string {
|
|
336
|
+
try {
|
|
337
|
+
return new Intl.DateTimeFormat('en-CA', { timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit' }).format(new Date());
|
|
338
|
+
} catch {
|
|
339
|
+
return new Date().toISOString().slice(0, 10);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* At `<inbox>/<date>-<name>`, or `-2`, `-3`… if taken. Never replaces a file: link() fails if anything (a symlink
|
|
345
|
+
* included) is already there, and follows nothing at the target. A filesystem without hard links has no such step
|
|
346
|
+
* (Node has no rename that refuses to replace), and a check before a rename is a race a Boton can win: refused there.
|
|
347
|
+
*/
|
|
348
|
+
async function place(tmp: string, inbox: string, prefix: string, name: string): Promise<string> {
|
|
349
|
+
const dot = name.lastIndexOf('.');
|
|
350
|
+
const [stem, ext] = dot > 0 ? [name.slice(0, dot), name.slice(dot)] : [name, ''];
|
|
351
|
+
for (let n = 1; n < 1000; n++) {
|
|
352
|
+
const candidate = `${prefix}-${stem}${n === 1 ? '' : `-${n}`}${ext}`;
|
|
353
|
+
try {
|
|
354
|
+
await placeLink(tmp, join(inbox, candidate));
|
|
355
|
+
await unlink(tmp);
|
|
356
|
+
return candidate;
|
|
357
|
+
} catch (err) {
|
|
358
|
+
const code = errCode(err);
|
|
359
|
+
if (code === 'EEXIST') continue;
|
|
360
|
+
if (code === 'EPERM' || code === 'ENOTSUP' || code === 'EOPNOTSUPP' || code === 'ENOSYS') throw new HttpError(507, NO_LINKS);
|
|
361
|
+
throw err;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
throw new HttpError(409, 'Too many files with that name today');
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
export const NO_LINKS = "This workspace's filesystem has no hard links, so Botons can't put a file there without risking replacing one; nothing was saved";
|
|
368
|
+
|
|
369
|
+
let placeLink: (from: string, to: string) => Promise<void> = link;
|
|
370
|
+
|
|
371
|
+
/** Tests only (set in code, never from a setting): the step that puts a finished file in place. */
|
|
372
|
+
export function usePlaceLink(fn: ((from: string, to: string) => Promise<void>) | null): void {
|
|
373
|
+
placeLink = fn ?? link;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* `name` (a folder of `root`, checked to be one) held open, as a path that names its entries through the open folder
|
|
378
|
+
* (held-dir.ts): a file created, linked into place or removed through it stays in that folder, even if the Boton swaps
|
|
379
|
+
* the folder for a link meanwhile. Where lookups in an open folder aren't available (not Linux) nothing is written:
|
|
380
|
+
* a folder looked up again by its path is what such a swap redirects.
|
|
381
|
+
*/
|
|
382
|
+
function holdFolder(root: string, name: string): { path: string; close: () => void } {
|
|
383
|
+
if (!heldLookups) throw new HttpError(501, `Botons writes into a workspace only through a folder it holds open (Linux /proc/self/fd), which this system doesn't have; nothing was saved in ${name}/`);
|
|
384
|
+
const top = holdDir(root);
|
|
385
|
+
try {
|
|
386
|
+
const dir = holdBeneath(top, [name]);
|
|
387
|
+
return { path: heldPath(dir), close: () => closeSync(dir.fd) };
|
|
388
|
+
} catch {
|
|
389
|
+
throw new HttpError(409, `${name}/ in the workspace is not a folder`);
|
|
390
|
+
} finally {
|
|
391
|
+
closeSync(top.fd);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* A new file the server itself writes into a Boton's workspace (a browser screenshot, say), at
|
|
397
|
+
* `<folder>/<prefix>-<name>`, never replacing anything. The Boton owns the workspace and may race the server, so no
|
|
398
|
+
* step follows a link it planted: the folder must be a real folder (lstat, not a link) and is held open (holdFolder),
|
|
399
|
+
* the temp file is created fresh in it with O_EXCL and O_NOFOLLOW, where that folder is now is checked before a byte is
|
|
400
|
+
* written, and the file is linked into place there (place()). Without a held folder or hard links, nothing is saved.
|
|
401
|
+
*/
|
|
402
|
+
export async function saveNewFile(workspace: string, folder: string, prefix: string, name: string, bytes: Uint8Array): Promise<FileRef> {
|
|
403
|
+
if (!/^[A-Za-z0-9._-]{1,64}$/.test(folder) || folder.startsWith('.')) throw new Error(`not a folder name: ${folder}`);
|
|
404
|
+
const root = await workspaceRoot(workspace);
|
|
405
|
+
const dir = join(root, folder);
|
|
406
|
+
try {
|
|
407
|
+
await mkdir(dir);
|
|
408
|
+
} catch (err) {
|
|
409
|
+
if (errCode(err) !== 'EEXIST') throw err;
|
|
410
|
+
}
|
|
411
|
+
const d = await lstat(dir);
|
|
412
|
+
if (d.isSymbolicLink() || !d.isDirectory()) throw new HttpError(409, `${folder}/ in the workspace is not a folder`);
|
|
413
|
+
const held = holdFolder(root, folder);
|
|
414
|
+
const base = held.path;
|
|
415
|
+
const tmp = join(base, `.save-${randomBytes(8).toString('hex')}.part`);
|
|
416
|
+
let fh: FileHandle;
|
|
417
|
+
try {
|
|
418
|
+
fh = await open(tmp, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW ?? 0), 0o644);
|
|
419
|
+
} catch (err) {
|
|
420
|
+
held.close();
|
|
421
|
+
throw err;
|
|
422
|
+
}
|
|
423
|
+
let placed: string | null = null;
|
|
424
|
+
try {
|
|
425
|
+
// Created through the held folder, so it is in that folder; where that folder is now must still be the workspace.
|
|
426
|
+
const where = await readlink(`/proc/self/fd/${fh.fd}`);
|
|
427
|
+
if (!inside(root, where)) throw new HttpError(409, `${folder}/ in the workspace moved while the file was saved`);
|
|
428
|
+
await fh.writeFile(bytes);
|
|
429
|
+
await fh.close();
|
|
430
|
+
const saved = await place(tmp, base, prefix, safeName(name));
|
|
431
|
+
placed = join(base, saved);
|
|
432
|
+
const st = await lstat(placed);
|
|
433
|
+
return { name: saved, path: `${folder}/${saved}`, size: st.size, type: mimeType(saved) };
|
|
434
|
+
} catch (err) {
|
|
435
|
+
await unlink(tmp).catch(() => {});
|
|
436
|
+
if (placed) await unlink(placed).catch(() => {});
|
|
437
|
+
throw err;
|
|
438
|
+
} finally {
|
|
439
|
+
await fh.close().catch(() => {});
|
|
440
|
+
held.close();
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/** Written to a hidden temp file first, so a file in the inbox is always whole. */
|
|
445
|
+
export async function receiveUpload(req: IncomingMessage, workspace: string, rawName: string, tz: string): Promise<FileRef> {
|
|
446
|
+
const ct = String(req.headers['content-type'] ?? '').toLowerCase();
|
|
447
|
+
if (!ct.startsWith('application/octet-stream')) throw new HttpError(415, 'Send the file as the body, with Content-Type: application/octet-stream');
|
|
448
|
+
const tooBig = () => new HttpError(413, `That file is too big: uploads are limited to ${Math.round(UPLOAD_MAX_BYTES / 1024 / 1024)} MB`, { connection: 'close' });
|
|
449
|
+
const declared = Number(req.headers['content-length']);
|
|
450
|
+
if (Number.isFinite(declared) && declared > UPLOAD_MAX_BYTES) throw tooBig();
|
|
451
|
+
const name = safeName(rawName);
|
|
452
|
+
|
|
453
|
+
const root = await workspaceRoot(workspace);
|
|
454
|
+
await inboxOf(root);
|
|
455
|
+
const held = holdFolder(root, INBOX);
|
|
456
|
+
const base = held.path;
|
|
457
|
+
const tmp = join(base, `.upload-${randomBytes(8).toString('hex')}.part`);
|
|
458
|
+
let fh: FileHandle;
|
|
459
|
+
try {
|
|
460
|
+
await sweepParts(base);
|
|
461
|
+
// `wx`: a fresh file, never one (or a symlink) already there.
|
|
462
|
+
fh = await open(tmp, 'wx');
|
|
463
|
+
} catch (err) {
|
|
464
|
+
held.close();
|
|
465
|
+
throw err;
|
|
466
|
+
}
|
|
467
|
+
let size = 0;
|
|
468
|
+
try {
|
|
469
|
+
const where = await readlink(`/proc/self/fd/${fh.fd}`);
|
|
470
|
+
if (!inside(root, where)) throw new HttpError(403, OUTSIDE);
|
|
471
|
+
await new Promise<void>((done, fail) => {
|
|
472
|
+
const out = fh.createWriteStream();
|
|
473
|
+
let failed = false;
|
|
474
|
+
const stop = (err: Error) => {
|
|
475
|
+
if (failed) return;
|
|
476
|
+
failed = true;
|
|
477
|
+
req.unpipe(out);
|
|
478
|
+
out.destroy();
|
|
479
|
+
fail(err);
|
|
480
|
+
};
|
|
481
|
+
req.on('data', (chunk: Buffer) => {
|
|
482
|
+
size += chunk.length;
|
|
483
|
+
if (size > UPLOAD_MAX_BYTES) {
|
|
484
|
+
req.pause();
|
|
485
|
+
stop(tooBig());
|
|
486
|
+
}
|
|
487
|
+
});
|
|
488
|
+
req.once('close', () => {
|
|
489
|
+
if (!req.complete) stop(new HttpError(400, 'The upload was cut off'));
|
|
490
|
+
});
|
|
491
|
+
req.once('error', () => stop(new HttpError(400, 'The upload was cut off')));
|
|
492
|
+
out.once('error', stop);
|
|
493
|
+
out.once('close', () => {
|
|
494
|
+
if (!failed) done();
|
|
495
|
+
});
|
|
496
|
+
req.pipe(out);
|
|
497
|
+
});
|
|
498
|
+
// Placed through the held inbox, it is there even if the inbox was swapped for a link meanwhile.
|
|
499
|
+
const saved = await place(tmp, base, today(tz), name);
|
|
500
|
+
return { name: saved, path: `${INBOX}/${saved}`, size, type: mimeType(saved) };
|
|
501
|
+
} catch (err) {
|
|
502
|
+
await unlink(tmp).catch(() => {});
|
|
503
|
+
throw err;
|
|
504
|
+
} finally {
|
|
505
|
+
await fh.close().catch(() => {});
|
|
506
|
+
held.close();
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
export function fileRef(found: Resolved): FileRef {
|
|
511
|
+
const name = found.abs.slice(found.abs.lastIndexOf(sep) + 1);
|
|
512
|
+
return { name, path: found.rel, size: found.stat.size, type: mimeType(name) };
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
export async function attachmentsOf(workspace: string, input: unknown): Promise<FileRef[]> {
|
|
516
|
+
if (input === undefined || input === null) return [];
|
|
517
|
+
if (!Array.isArray(input)) throw new HttpError(400, 'attachments must be a list of { path }');
|
|
518
|
+
if (input.length > ATTACHMENTS_MAX) throw new HttpError(400, `At most ${ATTACHMENTS_MAX} attachments per message`);
|
|
519
|
+
const out: FileRef[] = [];
|
|
520
|
+
for (const a of input) {
|
|
521
|
+
const path = (a as { path?: unknown } | null)?.path;
|
|
522
|
+
if (typeof path !== 'string' || !path) throw new HttpError(400, 'Each attachment needs a path');
|
|
523
|
+
const found = await resolveInWorkspace(workspace, path);
|
|
524
|
+
if (!found.stat.isFile()) throw new HttpError(400, `${path} is not a file`);
|
|
525
|
+
if (!out.some((f) => f.path === found.rel)) out.push(fileRef(found));
|
|
526
|
+
}
|
|
527
|
+
return out;
|
|
528
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { outbound, type OutboundRequest, type OutboundResponse } from './outbound.ts';
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
* What a GitHub token can see, for the owner's test of the GitHub preset: one read-only REST request to GitHub's own
|
|
5
|
+
* API host (never the connector's URL or anywhere else), asking for at most one private repository. Listing the MCP
|
|
6
|
+
* server's tools says nothing about which repositories the token was given, and this request reads only their names,
|
|
7
|
+
* so a token that sees one private repository may still lack Contents: Read on it.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export const GITHUB_API = 'https://api.github.com/user/repos?visibility=private&per_page=1';
|
|
11
|
+
|
|
12
|
+
export const GITHUB_REJECTED = 'GitHub rejected the token (expired or deleted?)';
|
|
13
|
+
export const GITHUB_PUBLIC_ONLY = 'This token can only see public repos. Give it Repository access to the repos you want and at least Contents: Read.';
|
|
14
|
+
|
|
15
|
+
/** rejected: 401; public-only: no private repository, or 404; unknown: anything else (said nothing about access). */
|
|
16
|
+
export type GithubAccess = 'private' | 'public-only' | 'rejected' | 'unknown';
|
|
17
|
+
|
|
18
|
+
export type Request = (url: string, opts: OutboundRequest) => Promise<OutboundResponse>;
|
|
19
|
+
|
|
20
|
+
/** The Authorization header among saved headers, in any case, or null. */
|
|
21
|
+
export function authorizationOf(headers: Record<string, string>): string | null {
|
|
22
|
+
for (const [k, v] of Object.entries(headers)) if (k.toLowerCase() === 'authorization' && v) return v;
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function githubAccess(authorization: string, signal?: AbortSignal, request: Request = outbound): Promise<GithubAccess> {
|
|
27
|
+
let res: OutboundResponse;
|
|
28
|
+
try {
|
|
29
|
+
res = await request(GITHUB_API, {
|
|
30
|
+
method: 'GET',
|
|
31
|
+
// Only the token goes to GitHub: none of the connector's other headers.
|
|
32
|
+
headers: { authorization, accept: 'application/vnd.github+json', 'x-github-api-version': '2022-11-28', 'user-agent': 'Botons' },
|
|
33
|
+
allow: [],
|
|
34
|
+
maxBytes: 256 * 1024,
|
|
35
|
+
timeoutMs: 10_000,
|
|
36
|
+
signal,
|
|
37
|
+
});
|
|
38
|
+
} catch {
|
|
39
|
+
return 'unknown';
|
|
40
|
+
}
|
|
41
|
+
if (res.status === 401) return 'rejected';
|
|
42
|
+
if (res.status === 404) return 'public-only';
|
|
43
|
+
if (res.status !== 200) return 'unknown';
|
|
44
|
+
let list: unknown;
|
|
45
|
+
try {
|
|
46
|
+
list = JSON.parse(res.body);
|
|
47
|
+
} catch {
|
|
48
|
+
return 'unknown';
|
|
49
|
+
}
|
|
50
|
+
if (!Array.isArray(list)) return 'unknown';
|
|
51
|
+
return list.some((r) => r && typeof r === 'object' && (r as { private?: unknown }).private === true) ? 'private' : 'public-only';
|
|
52
|
+
}
|