aionflare-connect 0.0.0-stage → 0.2.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 +31 -2
- package/bin/aionflare-connect.mjs +137 -0
- package/lib/core.mjs +178 -0
- package/lib/folders.mjs +108 -0
- package/lib/server.mjs +301 -0
- package/lib/ws.mjs +87 -0
- package/package.json +21 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 AIONFLARE
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,32 @@
|
|
|
1
|
-
#
|
|
1
|
+
# aionflare-connect
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A local terminal for AIONFLARE Coder ([ADR-0094](../../docs/adr/0094-coder-cloud-terminal.md), amendment of 2026-11-07). It lets Coder's **Local terminal** open shells on your own computer, in a folder you choose, with your own tools, Git keys and internet.
|
|
4
|
+
|
|
5
|
+
## Use
|
|
6
|
+
|
|
7
|
+
1. Install [Node.js](https://nodejs.org) (LTS). Windows and macOS use node-pty's ready-made binaries; on Linux, node-pty is built when installed, which needs Python, `make` and a C++ compiler.
|
|
8
|
+
2. In a normal (not Administrator) terminal, go to your project's folder and run:
|
|
9
|
+
|
|
10
|
+
```
|
|
11
|
+
npx aionflare-connect
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
3. Open the pairing link it prints, in the browser where you use AIONFLARE, within 10 minutes. Your browser may ask whether aionflare.com may connect to devices on your computer: allow it.
|
|
15
|
+
4. In a Coder project, turn on **Terminal** in the Environment panel (under Code). A terminal opens beside the chat; **+** opens another. Browsers paired before reconnect by themselves whenever the helper is running.
|
|
16
|
+
|
|
17
|
+
Press **Ctrl+C** to stop it and close every shell. `npx aionflare-connect --forget` forgets every paired browser. `--help` lists the options.
|
|
18
|
+
|
|
19
|
+
## What it can do
|
|
20
|
+
|
|
21
|
+
- It listens on this computer only (127.0.0.1), never on your network.
|
|
22
|
+
- It accepts a connection only from the AIONFLARE website, naming this computer's address, with a key it gave that browser when you paired it. Keys are kept as their SHA-256 in `~/.aionflare/connect.json`.
|
|
23
|
+
- It opens up to 3 shells (PowerShell on Windows, your login shell elsewhere), starting in the chosen folder.
|
|
24
|
+
- For a Coder project, it can open your computer's own folder dialog so you choose the app's folder (an existing one, or a new one named after the project). It then works in that folder, and remembers the folders you picked; the website can switch it only to one of those.
|
|
25
|
+
- For Coder's **Copy project here** and **Save to project**, it reads and writes text files only inside the chosen folder, never through links, skipping `node_modules`, `.git` and build folders.
|
|
26
|
+
- It does nothing else. It makes no internet connections and sends nothing to AIONFLARE. Its window shows a line when a browser pairs, connects or opens a shell, never what you type.
|
|
27
|
+
|
|
28
|
+
A shell has your account's own permissions and can go anywhere your account can, as any terminal can. The chosen folder limits only Coder's file actions. Run only commands you trust.
|
|
29
|
+
|
|
30
|
+
## Develop
|
|
31
|
+
|
|
32
|
+
Its rules (`lib/core.mjs`) and its WebSocket (`lib/ws.mjs`) are tested by `tests/connect.test.ts` in this repository. `--dev` also accepts a Workspace served from this computer (`npm run workspace`).
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// ADR-0094 amendment (owner, 2026-11-07): `npx aionflare-connect [folder]` lets Coder's Local
|
|
3
|
+
// terminal open shells on this computer, in this folder. See README.md.
|
|
4
|
+
import { spawnSync } from 'node:child_process';
|
|
5
|
+
import { chmod, mkdir, readFile, stat, writeFile } from 'node:fs/promises';
|
|
6
|
+
import { homedir, platform } from 'node:os';
|
|
7
|
+
import { join, resolve } from 'node:path';
|
|
8
|
+
import { defaultPort, newCode, version } from '../lib/core.mjs';
|
|
9
|
+
import { startServer } from '../lib/server.mjs';
|
|
10
|
+
|
|
11
|
+
const args = process.argv.slice(2);
|
|
12
|
+
const flag = (name) => args.includes(name);
|
|
13
|
+
const option = (name) => {
|
|
14
|
+
const at = args.indexOf(name);
|
|
15
|
+
return at >= 0 ? args[at + 1] : undefined;
|
|
16
|
+
};
|
|
17
|
+
const say = (line = '') => process.stdout.write(line + '\n');
|
|
18
|
+
|
|
19
|
+
if (flag('--version')) {
|
|
20
|
+
say(version);
|
|
21
|
+
process.exit(0);
|
|
22
|
+
}
|
|
23
|
+
if (flag('--help') || flag('-h')) {
|
|
24
|
+
say(`aionflare-connect ${version}: a local terminal for AIONFLARE Coder.
|
|
25
|
+
|
|
26
|
+
Usage: npx aionflare-connect [folder] [options]
|
|
27
|
+
|
|
28
|
+
folder The folder your shells start in and Coder may copy files to and from
|
|
29
|
+
(the current folder when left out).
|
|
30
|
+
--forget Forget every browser paired with this computer, then stop.
|
|
31
|
+
--port <n> Listen on this port (default ${defaultPort}).
|
|
32
|
+
--allow-admin Run even as Administrator or root (not recommended).
|
|
33
|
+
--dev Also accept a Workspace served from this computer (for AIONFLARE's developers).
|
|
34
|
+
|
|
35
|
+
It listens on this computer only and accepts only the AIONFLARE website. Press Ctrl+C to stop
|
|
36
|
+
it and close every shell.`);
|
|
37
|
+
process.exit(0);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const configDir = join(homedir(), '.aionflare');
|
|
41
|
+
const configFile = join(configDir, 'connect.json');
|
|
42
|
+
async function loadConfig() {
|
|
43
|
+
try {
|
|
44
|
+
const raw = JSON.parse(await readFile(configFile, 'utf8'));
|
|
45
|
+
return {
|
|
46
|
+
port: Number(raw.port) || undefined,
|
|
47
|
+
devices: Array.isArray(raw.devices) ? raw.devices : [],
|
|
48
|
+
// Owner (2026-11-07): the folders picked for projects in this computer's folder dialog.
|
|
49
|
+
folders: Array.isArray(raw.folders) ? raw.folders.filter((f) => typeof f === 'string') : [],
|
|
50
|
+
};
|
|
51
|
+
} catch {
|
|
52
|
+
return { devices: [] };
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
async function saveConfig(config) {
|
|
56
|
+
await mkdir(configDir, { recursive: true });
|
|
57
|
+
await writeFile(configFile, JSON.stringify(config, null, 2), { encoding: 'utf8', mode: 0o600 });
|
|
58
|
+
// Readable by this account only (on Windows the user's profile folder already is).
|
|
59
|
+
await chmod(configFile, 0o600).catch(() => {});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const config = await loadConfig();
|
|
63
|
+
if (flag('--forget')) {
|
|
64
|
+
config.devices = [];
|
|
65
|
+
await saveConfig(config);
|
|
66
|
+
say('Forgot every paired browser. Pair again with npx aionflare-connect.');
|
|
67
|
+
process.exit(0);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Shells have this account's permissions, so an Administrator or root account is refused unless
|
|
71
|
+
// asked for.
|
|
72
|
+
const admin =
|
|
73
|
+
platform() === 'win32'
|
|
74
|
+
? spawnSync('net', ['session'], { stdio: 'ignore', windowsHide: true }).status === 0
|
|
75
|
+
: process.getuid?.() === 0;
|
|
76
|
+
if (admin && !flag('--allow-admin')) {
|
|
77
|
+
say('This window runs as Administrator (or root). Shells opened from AIONFLARE would have full');
|
|
78
|
+
say(
|
|
79
|
+
'control of this computer, so aionflare-connect will not start. Open a normal window and run',
|
|
80
|
+
);
|
|
81
|
+
say('it again, or add --allow-admin if you really need it.');
|
|
82
|
+
process.exit(1);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const folderArg = args.find((a, i) => !a.startsWith('--') && args[i - 1] !== '--port');
|
|
86
|
+
const folder = resolve(folderArg ?? process.cwd());
|
|
87
|
+
if (!(await stat(folder).catch(() => null))?.isDirectory()) {
|
|
88
|
+
say('No folder at ' + folder + '.');
|
|
89
|
+
process.exit(1);
|
|
90
|
+
}
|
|
91
|
+
const dev = flag('--dev');
|
|
92
|
+
const wanted = Number(option('--port')) || config.port || defaultPort;
|
|
93
|
+
const log = (line) => say(new Date().toLocaleTimeString() + ' ' + line);
|
|
94
|
+
|
|
95
|
+
let helper;
|
|
96
|
+
let port = wanted;
|
|
97
|
+
for (let attempt = 0; attempt < 10; attempt++, port++) {
|
|
98
|
+
const candidate = startServer({ folder, port, dev, config, saveConfig, log });
|
|
99
|
+
try {
|
|
100
|
+
await candidate.listen();
|
|
101
|
+
helper = candidate;
|
|
102
|
+
break;
|
|
103
|
+
} catch (e) {
|
|
104
|
+
if (e?.code !== 'EADDRINUSE') throw e;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
if (!helper) {
|
|
108
|
+
say('Ports ' + wanted + ' to ' + (wanted + 9) + ' are in use. Try --port with another number.');
|
|
109
|
+
process.exit(1);
|
|
110
|
+
}
|
|
111
|
+
if (config.port !== port) {
|
|
112
|
+
config.port = port;
|
|
113
|
+
await saveConfig(config);
|
|
114
|
+
}
|
|
115
|
+
const code = newCode();
|
|
116
|
+
helper.newPairing(code);
|
|
117
|
+
|
|
118
|
+
say('');
|
|
119
|
+
say(' aionflare-connect ' + version + ' is running for ' + folder);
|
|
120
|
+
say('');
|
|
121
|
+
if (config.devices.length)
|
|
122
|
+
say(' Browsers paired before reconnect by themselves: open Terminal in Coder.');
|
|
123
|
+
say(' To pair a browser, open this link in it within 10 minutes:');
|
|
124
|
+
say('');
|
|
125
|
+
say(' https://aionflare.com/chat/#connect=' + port + '.' + code);
|
|
126
|
+
if (dev) say(' http://127.0.0.1:4310/#connect=' + port + '.' + code + ' (--dev)');
|
|
127
|
+
say('');
|
|
128
|
+
say(' Only this computer can connect, and only from the AIONFLARE website.');
|
|
129
|
+
say(' Press Ctrl+C to stop and close every shell.');
|
|
130
|
+
say('');
|
|
131
|
+
|
|
132
|
+
const stop = () => {
|
|
133
|
+
log('Stopping.');
|
|
134
|
+
process.exit(0);
|
|
135
|
+
};
|
|
136
|
+
process.on('SIGINT', stop);
|
|
137
|
+
process.on('SIGTERM', stop);
|
package/lib/core.mjs
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// ADR-0094 amendment (owner, 2026-11-07): aionflare-connect's rules, kept apart from its server so
|
|
2
|
+
// they can be tested on their own: who may connect, the pairing secrets, and which files in the
|
|
3
|
+
// chosen folder Coder may read and write.
|
|
4
|
+
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
5
|
+
import { lstat, mkdir, readdir, readFile, realpath, writeFile } from 'node:fs/promises';
|
|
6
|
+
import { dirname, join, relative, resolve, sep } from 'node:path';
|
|
7
|
+
|
|
8
|
+
export const version = '0.2.0';
|
|
9
|
+
// The messages the Workspace and the helper exchange; raised when they change incompatibly.
|
|
10
|
+
export const protocol = 1;
|
|
11
|
+
export const defaultPort = 47613;
|
|
12
|
+
// Up to 3 shells at once (ADR-0094 amendment).
|
|
13
|
+
export const maxShells = 3;
|
|
14
|
+
// A pairing code lasts 10 minutes.
|
|
15
|
+
export const codeMs = 10 * 60_000;
|
|
16
|
+
|
|
17
|
+
// Only the AIONFLARE website, or with --dev a Workspace served from this computer.
|
|
18
|
+
const siteOrigins = ['https://aionflare.com', 'https://www.aionflare.com'];
|
|
19
|
+
export function originAllowed(origin, dev = false) {
|
|
20
|
+
if (typeof origin !== 'string') return false;
|
|
21
|
+
if (siteOrigins.includes(origin)) return true;
|
|
22
|
+
return dev && /^http:\/\/(?:127\.0\.0\.1|localhost|\[::1\])(?::\d{1,5})?$/.test(origin);
|
|
23
|
+
}
|
|
24
|
+
// The Host header must name this computer and the helper's port: a page that points another host
|
|
25
|
+
// name at 127.0.0.1 (DNS rebinding) is refused.
|
|
26
|
+
export function hostAllowed(host, port) {
|
|
27
|
+
if (typeof host !== 'string') return false;
|
|
28
|
+
const h = host.toLowerCase();
|
|
29
|
+
return h === '127.0.0.1:' + port || h === 'localhost:' + port || h === '[::1]:' + port;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// A device key (256 bits) and a pairing code (128 bits); only their SHA-256 is kept.
|
|
33
|
+
export const newKey = () => randomBytes(32).toString('base64url');
|
|
34
|
+
export const newCode = () => randomBytes(16).toString('base64url');
|
|
35
|
+
export const newDeviceId = () => randomBytes(6).toString('hex');
|
|
36
|
+
export const hashSecret = (secret) => createHash('sha256').update(String(secret)).digest('hex');
|
|
37
|
+
export function sameHash(a, b) {
|
|
38
|
+
const hex = /^[0-9a-f]{64}$/;
|
|
39
|
+
if (typeof a !== 'string' || typeof b !== 'string' || !hex.test(a) || !hex.test(b)) return false;
|
|
40
|
+
return timingSafeEqual(Buffer.from(a, 'hex'), Buffer.from(b, 'hex'));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Coder's own rules for a project file (packages/tools/src/coder.ts): a safe relative path of
|
|
44
|
+
// letters, digits, `.`, `_`, `-` and `/`, at most six folders deep and 120 characters, with no `..`.
|
|
45
|
+
export const fileLimits = { path: 120, fileChars: 100_000, files: 60, projectBytes: 2_000_000 };
|
|
46
|
+
export function coderPath(value) {
|
|
47
|
+
if (typeof value !== 'string') return null;
|
|
48
|
+
const path = value
|
|
49
|
+
.trim()
|
|
50
|
+
.replace(/^\.?\/+/, '')
|
|
51
|
+
.replace(/\\/g, '/');
|
|
52
|
+
if (!path || path.length > fileLimits.path) return null;
|
|
53
|
+
if (!/^[A-Za-z0-9._/-]+$/.test(path)) return null;
|
|
54
|
+
const parts = path.split('/');
|
|
55
|
+
if (parts.length > 7 || parts.some((p) => !p || p === '.' || p === '..')) return null;
|
|
56
|
+
return path;
|
|
57
|
+
}
|
|
58
|
+
// Folders that stay in the folder and are never sent to a project: packages, Git, build output and
|
|
59
|
+
// caches (ADR-0094, sections 1 and 4).
|
|
60
|
+
export const excludedFolders = new Set([
|
|
61
|
+
'node_modules',
|
|
62
|
+
'.git',
|
|
63
|
+
'dist',
|
|
64
|
+
'build',
|
|
65
|
+
'coverage',
|
|
66
|
+
'.next',
|
|
67
|
+
'.nuxt',
|
|
68
|
+
'.svelte-kit',
|
|
69
|
+
'.cache',
|
|
70
|
+
'.turbo',
|
|
71
|
+
'.parcel-cache',
|
|
72
|
+
'.vercel',
|
|
73
|
+
'__pycache__',
|
|
74
|
+
'.venv',
|
|
75
|
+
'venv',
|
|
76
|
+
]);
|
|
77
|
+
const excludedFiles = new Set(['.DS_Store', 'Thumbs.db']);
|
|
78
|
+
|
|
79
|
+
const inside = (root, full) =>
|
|
80
|
+
full === root || full.startsWith(root.endsWith(sep) ? root : root + sep);
|
|
81
|
+
|
|
82
|
+
// The chosen folder's files Coder may take: text files under Coder's limits, walked without
|
|
83
|
+
// following links, skipping the excluded folders. What is skipped is named, with its reason.
|
|
84
|
+
export async function listFiles(root) {
|
|
85
|
+
const files = [];
|
|
86
|
+
const skipped = [];
|
|
87
|
+
let seen = 0;
|
|
88
|
+
async function walk(dir, depth) {
|
|
89
|
+
let entries;
|
|
90
|
+
try {
|
|
91
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
92
|
+
} catch {
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
96
|
+
for (const entry of entries) {
|
|
97
|
+
if (++seen > 5000) return;
|
|
98
|
+
const full = join(dir, entry.name);
|
|
99
|
+
const rel = relative(root, full).split(sep).join('/');
|
|
100
|
+
if (entry.isSymbolicLink()) {
|
|
101
|
+
skipped.push({ path: rel, reason: 'link' });
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
if (entry.isDirectory()) {
|
|
105
|
+
if (excludedFolders.has(entry.name)) continue;
|
|
106
|
+
if (depth >= 6) {
|
|
107
|
+
skipped.push({ path: rel + '/', reason: 'too deep' });
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
await walk(full, depth + 1);
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
if (!entry.isFile() || excludedFiles.has(entry.name)) continue;
|
|
114
|
+
if (!coderPath(rel)) {
|
|
115
|
+
skipped.push({ path: rel, reason: 'name' });
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
const info = await lstat(full).catch(() => null);
|
|
119
|
+
if (!info) continue;
|
|
120
|
+
if (info.size > fileLimits.fileChars * 4) {
|
|
121
|
+
skipped.push({ path: rel, reason: 'too large' });
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
files.push({ path: rel, bytes: info.size });
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
await walk(root, 0);
|
|
128
|
+
return { files, skipped };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// A file inside the folder, for reading or writing: a Coder path, resolved within the folder, and
|
|
132
|
+
// not reached through a link (the folder's real path is compared with each file's).
|
|
133
|
+
export async function resolveInside(root, value, forWrite = false) {
|
|
134
|
+
const path = coderPath(value);
|
|
135
|
+
if (!path) return null;
|
|
136
|
+
const realRoot = await realpath(root);
|
|
137
|
+
const full = resolve(realRoot, ...path.split('/'));
|
|
138
|
+
if (!inside(realRoot, full) || full === realRoot) return null;
|
|
139
|
+
const existing = await lstat(full).catch(() => null);
|
|
140
|
+
if (existing && (existing.isSymbolicLink() || !existing.isFile())) return null;
|
|
141
|
+
if (forWrite) {
|
|
142
|
+
// Each folder on the way is created inside the folder, never through a link.
|
|
143
|
+
let dir = realRoot;
|
|
144
|
+
for (const part of path.split('/').slice(0, -1)) {
|
|
145
|
+
dir = join(dir, part);
|
|
146
|
+
const info = await lstat(dir).catch(() => null);
|
|
147
|
+
if (info && (info.isSymbolicLink() || !info.isDirectory())) return null;
|
|
148
|
+
if (!info) await mkdir(dir);
|
|
149
|
+
}
|
|
150
|
+
const realDir = await realpath(dirname(full));
|
|
151
|
+
if (!inside(realRoot, realDir)) return null;
|
|
152
|
+
} else {
|
|
153
|
+
if (!existing) return null;
|
|
154
|
+
const real = await realpath(full);
|
|
155
|
+
if (!inside(realRoot, real)) return null;
|
|
156
|
+
}
|
|
157
|
+
return { path, full };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// A text file's content, or why it cannot be taken.
|
|
161
|
+
export async function readText(root, value) {
|
|
162
|
+
const at = await resolveInside(root, value);
|
|
163
|
+
if (!at) return { path: String(value), reason: 'not allowed' };
|
|
164
|
+
const data = await readFile(at.full);
|
|
165
|
+
if (data.includes(0)) return { path: at.path, reason: 'not text' };
|
|
166
|
+
const content = new TextDecoder('utf-8', { fatal: false }).decode(data);
|
|
167
|
+
if (content.includes('�')) return { path: at.path, reason: 'not text' };
|
|
168
|
+
if (content.length > fileLimits.fileChars) return { path: at.path, reason: 'too large' };
|
|
169
|
+
return { path: at.path, content };
|
|
170
|
+
}
|
|
171
|
+
export async function writeText(root, value, content) {
|
|
172
|
+
if (typeof content !== 'string' || content.length > fileLimits.fileChars)
|
|
173
|
+
return { path: String(value), reason: 'too large' };
|
|
174
|
+
const at = await resolveInside(root, value, true);
|
|
175
|
+
if (!at) return { path: String(value), reason: 'not allowed' };
|
|
176
|
+
await writeFile(at.full, content, 'utf8');
|
|
177
|
+
return { path: at.path };
|
|
178
|
+
}
|
package/lib/folders.mjs
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// Owner (2026-11-07): the app's folder for a Coder project, chosen by the member in their own
|
|
2
|
+
// computer's folder dialog (Windows Explorer, macOS Finder, or zenity on Linux): an existing folder,
|
|
3
|
+
// or a new one named after the project inside the folder they pick. The website never names a path:
|
|
4
|
+
// only folders picked here (or the one the helper started in) are used, and they are remembered in
|
|
5
|
+
// the helper's settings so a project finds its folder again.
|
|
6
|
+
import { spawn } from 'node:child_process';
|
|
7
|
+
import { mkdir, stat } from 'node:fs/promises';
|
|
8
|
+
import { platform } from 'node:os';
|
|
9
|
+
import { join, resolve } from 'node:path';
|
|
10
|
+
|
|
11
|
+
// A new folder's name: the project's name with only letters, digits, spaces, dots, dashes and
|
|
12
|
+
// underscores (60 at most).
|
|
13
|
+
export function folderName(name) {
|
|
14
|
+
const clean = String(name ?? '')
|
|
15
|
+
.replace(/[^A-Za-z0-9 ._-]+/g, ' ')
|
|
16
|
+
.replace(/\s+/g, ' ')
|
|
17
|
+
.trim()
|
|
18
|
+
.replace(/^[.\s]+|[.\s]+$/g, '')
|
|
19
|
+
.slice(0, 60);
|
|
20
|
+
return clean || 'Coder project';
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function run(file, args, env = {}) {
|
|
24
|
+
return new Promise((done) => {
|
|
25
|
+
const child = spawn(file, args, {
|
|
26
|
+
env: { ...process.env, ...env },
|
|
27
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
28
|
+
windowsHide: false,
|
|
29
|
+
});
|
|
30
|
+
let out = '';
|
|
31
|
+
child.stdout.on('data', (d) => (out += d));
|
|
32
|
+
child.on('error', () => done(null));
|
|
33
|
+
child.on('close', (code) => done(code === 0 ? out.trim() : null));
|
|
34
|
+
// A dialog left open is closed after 5 minutes.
|
|
35
|
+
setTimeout(() => child.kill(), 5 * 60_000);
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// The folder dialog of this computer, its title passed as an environment variable (never as code).
|
|
40
|
+
// Null when the member cancels or the computer has no dialog.
|
|
41
|
+
export async function pickFolder(title) {
|
|
42
|
+
const env = { AIONFLARE_DIALOG_TITLE: String(title).slice(0, 120) };
|
|
43
|
+
const os = platform();
|
|
44
|
+
if (os === 'win32')
|
|
45
|
+
return run(
|
|
46
|
+
'powershell.exe',
|
|
47
|
+
[
|
|
48
|
+
'-NoProfile',
|
|
49
|
+
'-STA',
|
|
50
|
+
'-Command',
|
|
51
|
+
'Add-Type -AssemblyName System.Windows.Forms;' +
|
|
52
|
+
'$d = New-Object System.Windows.Forms.FolderBrowserDialog;' +
|
|
53
|
+
'$d.Description = $env:AIONFLARE_DIALOG_TITLE;' +
|
|
54
|
+
'$d.UseDescriptionForTitle = $true;' +
|
|
55
|
+
'$d.ShowNewFolderButton = $true;' +
|
|
56
|
+
'$top = New-Object System.Windows.Forms.Form -Property @{TopMost = $true};' +
|
|
57
|
+
"if ($d.ShowDialog($top) -eq 'OK') { [Console]::Out.Write($d.SelectedPath) }",
|
|
58
|
+
],
|
|
59
|
+
env,
|
|
60
|
+
);
|
|
61
|
+
if (os === 'darwin')
|
|
62
|
+
return run(
|
|
63
|
+
'osascript',
|
|
64
|
+
[
|
|
65
|
+
'-e',
|
|
66
|
+
'set t to system attribute "AIONFLARE_DIALOG_TITLE"',
|
|
67
|
+
'-e',
|
|
68
|
+
'POSIX path of (choose folder with prompt t)',
|
|
69
|
+
],
|
|
70
|
+
env,
|
|
71
|
+
);
|
|
72
|
+
return (
|
|
73
|
+
(await run('zenity', [
|
|
74
|
+
'--file-selection',
|
|
75
|
+
'--directory',
|
|
76
|
+
'--title',
|
|
77
|
+
env.AIONFLARE_DIALOG_TITLE,
|
|
78
|
+
])) ??
|
|
79
|
+
run('kdialog', [
|
|
80
|
+
'--getexistingdirectory',
|
|
81
|
+
process.env.HOME ?? '/',
|
|
82
|
+
'--title',
|
|
83
|
+
env.AIONFLARE_DIALOG_TITLE,
|
|
84
|
+
])
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// "open": the folder picked. "create": a new folder named after the project inside the folder
|
|
89
|
+
// picked (a number added when the name is taken). Null when cancelled.
|
|
90
|
+
export async function chooseFolder(mode, name) {
|
|
91
|
+
const picked = await pickFolder(
|
|
92
|
+
mode === 'create'
|
|
93
|
+
? 'AIONFLARE Coder: choose where to create the folder for ' + folderName(name)
|
|
94
|
+
: 'AIONFLARE Coder: choose the folder of ' + folderName(name),
|
|
95
|
+
);
|
|
96
|
+
if (!picked) return null;
|
|
97
|
+
const base = resolve(picked.replace(/[\r\n]+$/g, ''));
|
|
98
|
+
if (!(await stat(base).catch(() => null))?.isDirectory()) return null;
|
|
99
|
+
if (mode !== 'create') return base;
|
|
100
|
+
const wanted = folderName(name);
|
|
101
|
+
for (let n = 1; n < 100; n++) {
|
|
102
|
+
const target = join(base, n === 1 ? wanted : wanted + ' ' + n);
|
|
103
|
+
if (await stat(target).catch(() => null)) continue;
|
|
104
|
+
await mkdir(target);
|
|
105
|
+
return target;
|
|
106
|
+
}
|
|
107
|
+
return null;
|
|
108
|
+
}
|
package/lib/server.mjs
ADDED
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
// ADR-0094 amendment (owner, 2026-11-07): the helper's server. It listens on this computer only
|
|
2
|
+
// (127.0.0.1), accepts a WebSocket from the AIONFLARE website alone, pairs a browser with a
|
|
3
|
+
// one-time code, and then opens the member's own shells and reads and writes files in the chosen
|
|
4
|
+
// folder. It never connects out and logs no commands, output or file contents.
|
|
5
|
+
import { createServer } from 'node:http';
|
|
6
|
+
import { createRequire } from 'node:module';
|
|
7
|
+
import { basename } from 'node:path';
|
|
8
|
+
import { platform } from 'node:os';
|
|
9
|
+
import {
|
|
10
|
+
codeMs,
|
|
11
|
+
fileLimits,
|
|
12
|
+
hashSecret,
|
|
13
|
+
hostAllowed,
|
|
14
|
+
listFiles,
|
|
15
|
+
maxShells,
|
|
16
|
+
newDeviceId,
|
|
17
|
+
newKey,
|
|
18
|
+
originAllowed,
|
|
19
|
+
protocol,
|
|
20
|
+
readText,
|
|
21
|
+
sameHash,
|
|
22
|
+
version,
|
|
23
|
+
writeText,
|
|
24
|
+
} from './core.mjs';
|
|
25
|
+
import { FrameReader, acceptKey, encodeFrame } from './ws.mjs';
|
|
26
|
+
import { chooseFolder } from './folders.mjs';
|
|
27
|
+
|
|
28
|
+
const require = createRequire(import.meta.url);
|
|
29
|
+
// A message can carry a whole project (2 MB of text, written as JSON).
|
|
30
|
+
const messageLimit = 8 * 1024 * 1024;
|
|
31
|
+
|
|
32
|
+
function shellFor() {
|
|
33
|
+
if (platform() === 'win32')
|
|
34
|
+
return { file: 'powershell.exe', args: ['-NoLogo'], name: 'PowerShell' };
|
|
35
|
+
const file = process.env.SHELL || '/bin/bash';
|
|
36
|
+
return { file, args: ['-l'], name: basename(file) };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function startServer({ folder: startFolder, port, dev, config, saveConfig, log }) {
|
|
40
|
+
// Owner (2026-11-07): the folder the helper works in: where it started, or one the member picked
|
|
41
|
+
// in their computer's folder dialog for a project (remembered in config.folders, the last 20).
|
|
42
|
+
let folder = startFolder;
|
|
43
|
+
const approved = () => [startFolder, ...(config.folders ?? [])];
|
|
44
|
+
const pty = require('node-pty');
|
|
45
|
+
const shell = shellFor();
|
|
46
|
+
// One pairing code each time the helper starts.
|
|
47
|
+
let pairing = { hash: '', expires: 0 };
|
|
48
|
+
let failures = 0;
|
|
49
|
+
let lockedUntil = 0;
|
|
50
|
+
let shells = 0;
|
|
51
|
+
const info = () => ({
|
|
52
|
+
t: 'ready',
|
|
53
|
+
version,
|
|
54
|
+
protocol,
|
|
55
|
+
platform: platform(),
|
|
56
|
+
shell: shell.name,
|
|
57
|
+
folder,
|
|
58
|
+
folder_name: basename(folder),
|
|
59
|
+
max_shells: maxShells,
|
|
60
|
+
features: ['choose', 'use'],
|
|
61
|
+
limits: fileLimits,
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const server = createServer((req, res) => {
|
|
65
|
+
// Only the WebSocket is served; nothing for other pages to read.
|
|
66
|
+
res.writeHead(426, { 'Content-Type': 'text/plain', 'Cache-Control': 'no-store' });
|
|
67
|
+
res.end('aionflare-connect accepts the AIONFLARE Workspace only.');
|
|
68
|
+
});
|
|
69
|
+
server.on('upgrade', (req, socket) => {
|
|
70
|
+
const refuse = (status) => {
|
|
71
|
+
socket.end('HTTP/1.1 ' + status + '\r\nConnection: close\r\n\r\n');
|
|
72
|
+
socket.destroy();
|
|
73
|
+
};
|
|
74
|
+
const key = req.headers['sec-websocket-key'];
|
|
75
|
+
if (
|
|
76
|
+
!hostAllowed(req.headers.host, port) ||
|
|
77
|
+
!originAllowed(req.headers.origin, dev) ||
|
|
78
|
+
String(req.headers.upgrade).toLowerCase() !== 'websocket' ||
|
|
79
|
+
req.headers['sec-websocket-version'] !== '13' ||
|
|
80
|
+
typeof key !== 'string' ||
|
|
81
|
+
!/^[A-Za-z0-9+/]{22}==$/.test(key)
|
|
82
|
+
)
|
|
83
|
+
return refuse('403 Forbidden');
|
|
84
|
+
socket.write(
|
|
85
|
+
'HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n' +
|
|
86
|
+
'Sec-WebSocket-Accept: ' +
|
|
87
|
+
acceptKey(key) +
|
|
88
|
+
'\r\n\r\n',
|
|
89
|
+
);
|
|
90
|
+
connection(socket);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
function connection(socket) {
|
|
94
|
+
let authed = null;
|
|
95
|
+
let closed = false;
|
|
96
|
+
const mine = new Map();
|
|
97
|
+
const send = (message) => {
|
|
98
|
+
if (!closed) socket.write(encodeFrame(0x1, JSON.stringify(message)));
|
|
99
|
+
};
|
|
100
|
+
const close = () => {
|
|
101
|
+
if (closed) return;
|
|
102
|
+
closed = true;
|
|
103
|
+
for (const term of mine.values()) term.kill();
|
|
104
|
+
mine.clear();
|
|
105
|
+
try {
|
|
106
|
+
socket.end(encodeFrame(0x8));
|
|
107
|
+
} catch {
|
|
108
|
+
/* already gone */
|
|
109
|
+
}
|
|
110
|
+
socket.destroy();
|
|
111
|
+
if (authed) log('Browser disconnected.');
|
|
112
|
+
};
|
|
113
|
+
// A browser that does not pair or sign in within 10 seconds is disconnected.
|
|
114
|
+
const signIn = setTimeout(() => !authed && close(), 10_000);
|
|
115
|
+
const reader = new FrameReader({
|
|
116
|
+
limit: messageLimit,
|
|
117
|
+
onText: (text) => {
|
|
118
|
+
let message;
|
|
119
|
+
try {
|
|
120
|
+
message = JSON.parse(text);
|
|
121
|
+
} catch {
|
|
122
|
+
return close();
|
|
123
|
+
}
|
|
124
|
+
handle(message).catch(() => send({ t: 'error', message: 'The helper could not do that.' }));
|
|
125
|
+
},
|
|
126
|
+
onClose: close,
|
|
127
|
+
onPing: (data) => socket.write(encodeFrame(0xa, data)),
|
|
128
|
+
onError: close,
|
|
129
|
+
});
|
|
130
|
+
socket.on('data', (chunk) => reader.push(chunk));
|
|
131
|
+
socket.on('close', close);
|
|
132
|
+
socket.on('error', close);
|
|
133
|
+
|
|
134
|
+
async function handle(m) {
|
|
135
|
+
if (!m || typeof m !== 'object') return close();
|
|
136
|
+
if (!authed) {
|
|
137
|
+
if (Date.now() < lockedUntil) {
|
|
138
|
+
send({ t: 'denied', reason: 'locked' });
|
|
139
|
+
return close();
|
|
140
|
+
}
|
|
141
|
+
if (m.t === 'pair' && typeof m.code === 'string') {
|
|
142
|
+
if (Date.now() > pairing.expires || !sameHash(hashSecret(m.code), pairing.hash))
|
|
143
|
+
return deny('code');
|
|
144
|
+
// The code is used once.
|
|
145
|
+
pairing = { hash: '', expires: 0 };
|
|
146
|
+
const id = newDeviceId();
|
|
147
|
+
const deviceKey = newKey();
|
|
148
|
+
config.devices = [
|
|
149
|
+
...(config.devices ?? []).slice(-9),
|
|
150
|
+
{ id, key_hash: hashSecret(deviceKey), paired_at: new Date().toISOString() },
|
|
151
|
+
];
|
|
152
|
+
await saveConfig(config);
|
|
153
|
+
authed = id;
|
|
154
|
+
clearTimeout(signIn);
|
|
155
|
+
log('Paired a browser (' + id + ').');
|
|
156
|
+
return send({ ...info(), t: 'paired', device_id: id, key: deviceKey });
|
|
157
|
+
}
|
|
158
|
+
if (m.t === 'auth' && typeof m.device_id === 'string' && typeof m.key === 'string') {
|
|
159
|
+
const device = (config.devices ?? []).find((d) => d.id === m.device_id);
|
|
160
|
+
if (!device || !sameHash(hashSecret(m.key), device.key_hash)) return deny('key');
|
|
161
|
+
authed = device.id;
|
|
162
|
+
failures = 0;
|
|
163
|
+
clearTimeout(signIn);
|
|
164
|
+
log('Browser connected (' + device.id + ').');
|
|
165
|
+
return send(info());
|
|
166
|
+
}
|
|
167
|
+
return deny('first');
|
|
168
|
+
}
|
|
169
|
+
const reply = (extra) => send({ t: 'res', rid: m.rid, ...extra });
|
|
170
|
+
switch (m.t) {
|
|
171
|
+
case 'open': {
|
|
172
|
+
if (shells >= maxShells)
|
|
173
|
+
return send({ t: 'error', id: m.id, message: 'Up to 3 shells at once.' });
|
|
174
|
+
const id = String(m.id ?? '').slice(0, 40);
|
|
175
|
+
if (!id || mine.has(id)) return;
|
|
176
|
+
const term = pty.spawn(shell.file, shell.args, {
|
|
177
|
+
name: 'xterm-256color',
|
|
178
|
+
cols: clamp(m.cols, 20, 400, 80),
|
|
179
|
+
rows: clamp(m.rows, 5, 200, 24),
|
|
180
|
+
cwd: folder,
|
|
181
|
+
env: { ...process.env, AIONFLARE_TERMINAL: '1' },
|
|
182
|
+
});
|
|
183
|
+
shells++;
|
|
184
|
+
mine.set(id, term);
|
|
185
|
+
log('Shell opened (' + shells + ' of ' + maxShells + ').');
|
|
186
|
+
term.onData((data) => send({ t: 'out', id, d: data }));
|
|
187
|
+
term.onExit(({ exitCode }) => {
|
|
188
|
+
mine.delete(id);
|
|
189
|
+
shells = Math.max(0, shells - 1);
|
|
190
|
+
log('Shell closed.');
|
|
191
|
+
send({ t: 'exit', id, code: exitCode });
|
|
192
|
+
});
|
|
193
|
+
return send({ t: 'opened', id, shell: shell.name });
|
|
194
|
+
}
|
|
195
|
+
case 'in':
|
|
196
|
+
if (typeof m.d === 'string') mine.get(m.id)?.write(m.d.slice(0, 65536));
|
|
197
|
+
return;
|
|
198
|
+
case 'size':
|
|
199
|
+
mine.get(m.id)?.resize(clamp(m.cols, 20, 400, 80), clamp(m.rows, 5, 200, 24));
|
|
200
|
+
return;
|
|
201
|
+
case 'kill':
|
|
202
|
+
mine.get(m.id)?.kill();
|
|
203
|
+
return;
|
|
204
|
+
case 'list':
|
|
205
|
+
return reply(await listFiles(folder));
|
|
206
|
+
case 'read': {
|
|
207
|
+
const paths = Array.isArray(m.paths) ? m.paths.slice(0, 300) : [];
|
|
208
|
+
const out = [];
|
|
209
|
+
for (const p of paths)
|
|
210
|
+
out.push(
|
|
211
|
+
await readText(folder, p).catch(() => ({ path: String(p), reason: 'unreadable' })),
|
|
212
|
+
);
|
|
213
|
+
return reply({
|
|
214
|
+
files: out.filter((f) => 'content' in f),
|
|
215
|
+
skipped: out.filter((f) => !('content' in f)),
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
case 'write': {
|
|
219
|
+
const files = Array.isArray(m.files) ? m.files.slice(0, 300) : [];
|
|
220
|
+
const out = [];
|
|
221
|
+
for (const f of files)
|
|
222
|
+
out.push(
|
|
223
|
+
await writeText(folder, f?.path, f?.content).catch(() => ({
|
|
224
|
+
path: String(f?.path),
|
|
225
|
+
reason: 'unwritable',
|
|
226
|
+
})),
|
|
227
|
+
);
|
|
228
|
+
log('Coder wrote ' + out.filter((f) => !f.reason).length + ' files.');
|
|
229
|
+
return reply({
|
|
230
|
+
written: out.filter((f) => !f.reason).length,
|
|
231
|
+
skipped: out.filter((f) => f.reason),
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
// Owner (2026-11-07): a project's folder, picked in this computer's own folder dialog
|
|
235
|
+
// (an existing one, or a new one named after the project); then the helper works there.
|
|
236
|
+
case 'choose': {
|
|
237
|
+
const picked = await chooseFolder(m.mode === 'create' ? 'create' : 'open', m.name);
|
|
238
|
+
if (!picked) return reply({ cancelled: true });
|
|
239
|
+
config.folders = [...(config.folders ?? []).filter((f) => f !== picked), picked].slice(
|
|
240
|
+
-20,
|
|
241
|
+
);
|
|
242
|
+
await saveConfig(config);
|
|
243
|
+
folder = picked;
|
|
244
|
+
log('Working in ' + folder + '.');
|
|
245
|
+
return reply({ folder, folder_name: basename(folder) });
|
|
246
|
+
}
|
|
247
|
+
// A folder picked before (or the one the helper started in), by its full path.
|
|
248
|
+
case 'use': {
|
|
249
|
+
const wanted = String(m.folder ?? '');
|
|
250
|
+
if (!approved().includes(wanted) || !(await isFolder(wanted)))
|
|
251
|
+
return reply({ refused: true });
|
|
252
|
+
folder = wanted;
|
|
253
|
+
return reply({ folder, folder_name: basename(folder) });
|
|
254
|
+
}
|
|
255
|
+
case 'forget': {
|
|
256
|
+
config.devices = (config.devices ?? []).filter((d) => d.id !== authed);
|
|
257
|
+
await saveConfig(config);
|
|
258
|
+
log('A browser forgot this computer (' + authed + ').');
|
|
259
|
+
send({ t: 'forgotten' });
|
|
260
|
+
return close();
|
|
261
|
+
}
|
|
262
|
+
default:
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
function deny(reason) {
|
|
267
|
+
// Ten wrong codes or keys lock pairing and sign-in for a minute.
|
|
268
|
+
if (++failures >= 10) {
|
|
269
|
+
lockedUntil = Date.now() + 60_000;
|
|
270
|
+
failures = 0;
|
|
271
|
+
}
|
|
272
|
+
send({ t: 'denied', reason });
|
|
273
|
+
close();
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
return {
|
|
278
|
+
server,
|
|
279
|
+
newPairing(code) {
|
|
280
|
+
pairing = { hash: hashSecret(code), expires: Date.now() + codeMs };
|
|
281
|
+
},
|
|
282
|
+
listen() {
|
|
283
|
+
return new Promise((ok, fail) => {
|
|
284
|
+
server.once('error', fail);
|
|
285
|
+
server.listen(port, '127.0.0.1', () => {
|
|
286
|
+
server.off('error', fail);
|
|
287
|
+
ok();
|
|
288
|
+
});
|
|
289
|
+
});
|
|
290
|
+
},
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
async function isFolder(path) {
|
|
295
|
+
const { stat } = await import('node:fs/promises');
|
|
296
|
+
return !!(await stat(path).catch(() => null))?.isDirectory();
|
|
297
|
+
}
|
|
298
|
+
function clamp(value, min, max, fallback) {
|
|
299
|
+
const n = Math.floor(Number(value));
|
|
300
|
+
return Number.isFinite(n) ? Math.min(max, Math.max(min, n)) : fallback;
|
|
301
|
+
}
|
package/lib/ws.mjs
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// ADR-0094 amendment: a small WebSocket server side (RFC 6455), so the helper depends on nothing but
|
|
2
|
+
// node-pty. Text messages only; the browser's frames are masked, the helper's are not.
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
|
|
5
|
+
const guid = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
|
|
6
|
+
export const acceptKey = (key) =>
|
|
7
|
+
createHash('sha1')
|
|
8
|
+
.update(key + guid)
|
|
9
|
+
.digest('base64');
|
|
10
|
+
|
|
11
|
+
// One frame to send: FIN set, unmasked, with its length in 7, 16 or 64 bits.
|
|
12
|
+
export function encodeFrame(opcode, payload = Buffer.alloc(0)) {
|
|
13
|
+
const body = Buffer.isBuffer(payload) ? payload : Buffer.from(String(payload), 'utf8');
|
|
14
|
+
const n = body.length;
|
|
15
|
+
const head =
|
|
16
|
+
n < 126
|
|
17
|
+
? Buffer.from([0x80 | opcode, n])
|
|
18
|
+
: n < 65536
|
|
19
|
+
? Buffer.from([0x80 | opcode, 126, n >> 8, n & 255])
|
|
20
|
+
: (() => {
|
|
21
|
+
const h = Buffer.alloc(10);
|
|
22
|
+
h[0] = 0x80 | opcode;
|
|
23
|
+
h[1] = 127;
|
|
24
|
+
h.writeBigUInt64BE(BigInt(n), 2);
|
|
25
|
+
return h;
|
|
26
|
+
})();
|
|
27
|
+
return Buffer.concat([head, body]);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Reads the browser's frames as bytes arrive and reports whole messages. Unmasked frames, binary
|
|
31
|
+
// messages and anything over the size limit end the connection.
|
|
32
|
+
export class FrameReader {
|
|
33
|
+
#buffer = Buffer.alloc(0);
|
|
34
|
+
#parts = [];
|
|
35
|
+
#size = 0;
|
|
36
|
+
constructor({ limit, onText, onClose, onPing, onError }) {
|
|
37
|
+
Object.assign(this, { limit, onText, onClose, onPing, onError });
|
|
38
|
+
}
|
|
39
|
+
push(chunk) {
|
|
40
|
+
this.#buffer = Buffer.concat([this.#buffer, chunk]);
|
|
41
|
+
for (;;) {
|
|
42
|
+
const b = this.#buffer;
|
|
43
|
+
if (b.length < 2) return;
|
|
44
|
+
const fin = (b[0] & 0x80) !== 0;
|
|
45
|
+
const opcode = b[0] & 0x0f;
|
|
46
|
+
const masked = (b[1] & 0x80) !== 0;
|
|
47
|
+
let length = b[1] & 0x7f;
|
|
48
|
+
let at = 2;
|
|
49
|
+
if (length === 126) {
|
|
50
|
+
if (b.length < 4) return;
|
|
51
|
+
length = b.readUInt16BE(2);
|
|
52
|
+
at = 4;
|
|
53
|
+
} else if (length === 127) {
|
|
54
|
+
if (b.length < 10) return;
|
|
55
|
+
const big = b.readBigUInt64BE(2);
|
|
56
|
+
if (big > BigInt(this.limit)) return this.onError('too large');
|
|
57
|
+
length = Number(big);
|
|
58
|
+
at = 10;
|
|
59
|
+
}
|
|
60
|
+
if (!masked) return this.onError('unmasked');
|
|
61
|
+
if (length > this.limit) return this.onError('too large');
|
|
62
|
+
if (b.length < at + 4 + length) return;
|
|
63
|
+
const mask = b.subarray(at, at + 4);
|
|
64
|
+
const payload = Buffer.from(b.subarray(at + 4, at + 4 + length));
|
|
65
|
+
for (let i = 0; i < payload.length; i++) payload[i] ^= mask[i & 3];
|
|
66
|
+
this.#buffer = b.subarray(at + 4 + length);
|
|
67
|
+
if (opcode === 0x8) return this.onClose();
|
|
68
|
+
if (opcode === 0x9) {
|
|
69
|
+
this.onPing(payload);
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if (opcode === 0xa) continue;
|
|
73
|
+
if (opcode === 0x2) return this.onError('binary');
|
|
74
|
+
if (opcode !== 0x1 && opcode !== 0x0) return this.onError('opcode');
|
|
75
|
+
if ((opcode === 0x1) === this.#parts.length > 0) return this.onError('fragments');
|
|
76
|
+
this.#size += payload.length;
|
|
77
|
+
if (this.#size > this.limit) return this.onError('too large');
|
|
78
|
+
this.#parts.push(payload);
|
|
79
|
+
if (fin) {
|
|
80
|
+
const text = Buffer.concat(this.#parts).toString('utf8');
|
|
81
|
+
this.#parts = [];
|
|
82
|
+
this.#size = 0;
|
|
83
|
+
this.onText(text);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,23 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "aionflare-connect",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "A local terminal for AIONFLARE Coder: open shells on your own computer from the Workspace.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"aionflare-connect": "bin/aionflare-connect.mjs"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin",
|
|
11
|
+
"lib",
|
|
12
|
+
"README.md",
|
|
13
|
+
"LICENSE"
|
|
14
|
+
],
|
|
15
|
+
"engines": {
|
|
16
|
+
"node": ">=20"
|
|
17
|
+
},
|
|
18
|
+
"dependencies": {
|
|
19
|
+
"node-pty": "1.1.0"
|
|
20
|
+
},
|
|
21
|
+
"license": "MIT",
|
|
22
|
+
"homepage": "https://aionflare.com"
|
|
23
|
+
}
|