@ours.network/fleet 0.10.3 → 0.10.4
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/README.md +66 -0
- package/dist/application/capabilities.d.ts +6 -0
- package/dist/application/capabilities.js +37 -0
- package/dist/application/errors.d.ts +31 -0
- package/dist/application/errors.js +51 -0
- package/dist/application/fleet-query-service.d.ts +31 -0
- package/dist/application/fleet-query-service.js +180 -0
- package/dist/application/log-service.d.ts +28 -0
- package/dist/application/log-service.js +146 -0
- package/dist/application/role-command-service.d.ts +37 -0
- package/dist/application/role-command-service.js +82 -0
- package/dist/application/role-creation-service.d.ts +142 -0
- package/dist/application/role-creation-service.js +374 -0
- package/dist/application/role-repository.d.ts +20 -0
- package/dist/application/role-repository.js +168 -0
- package/dist/application/session-control.d.ts +55 -0
- package/dist/application/session-control.js +115 -0
- package/dist/application/types.d.ts +156 -0
- package/dist/application/types.js +1 -0
- package/dist/cli.js +141 -0
- package/dist/config.d.ts +7 -2
- package/dist/config.js +18 -4
- package/dist/creation.d.ts +11 -0
- package/dist/creation.js +22 -5
- package/dist/docs.d.ts +1 -1
- package/dist/docs.js +34 -0
- package/dist/index.d.ts +10 -1
- package/dist/index.js +9 -1
- package/dist/runner.js +10 -2
- package/dist/session/control.d.ts +4 -2
- package/dist/session/control.js +45 -13
- package/dist/spawn.d.ts +20 -2
- package/dist/spawn.js +94 -24
- package/dist/supervisor/launchd.js +17 -0
- package/dist/supervisor/none.js +17 -0
- package/dist/supervisor/systemd.js +4 -0
- package/dist/supervisor/types.d.ts +6 -0
- package/dist/tmux.d.ts +2 -0
- package/dist/tmux.js +8 -0
- package/dist/web/audit.d.ts +22 -0
- package/dist/web/audit.js +54 -0
- package/dist/web/auth.d.ts +61 -0
- package/dist/web/auth.js +186 -0
- package/dist/web/control.d.ts +14 -0
- package/dist/web/control.js +110 -0
- package/dist/web/device-store.d.ts +27 -0
- package/dist/web/device-store.js +155 -0
- package/dist/web/events.d.ts +15 -0
- package/dist/web/events.js +34 -0
- package/dist/web/lock.d.ts +5 -0
- package/dist/web/lock.js +69 -0
- package/dist/web/runtime.d.ts +12 -0
- package/dist/web/runtime.js +170 -0
- package/dist/web/server.d.ts +35 -0
- package/dist/web/server.js +261 -0
- package/dist/web/service.d.ts +42 -0
- package/dist/web/service.js +180 -0
- package/dist/web/terminal/bridge.d.ts +27 -0
- package/dist/web/terminal/bridge.js +317 -0
- package/dist/web-app/assets/TerminalView-DcImdrI1.js +9 -0
- package/dist/web-app/assets/index-BokQN1Ao.js +9 -0
- package/dist/web-app/assets/index-lAXzaOZM.css +1 -0
- package/dist/web-app/icons/ours-fleet-maskable.svg +4 -0
- package/dist/web-app/icons/ours-fleet.svg +4 -0
- package/dist/web-app/index.html +17 -0
- package/dist/web-app/manifest.webmanifest +15 -0
- package/dist/web-app/offline.html +18 -0
- package/dist/web-app/sw.js +51 -0
- package/package.json +26 -3
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, realpathSync, rmSync } from 'node:fs';
|
|
2
|
+
import { userInfo } from 'node:os';
|
|
3
|
+
import { dirname, isAbsolute, join, resolve } from 'node:path';
|
|
4
|
+
import { replaceFileAtomically } from '../atomic-file.js';
|
|
5
|
+
import { FleetError } from '../application/errors.js';
|
|
6
|
+
import { realExec } from '../exec.js';
|
|
7
|
+
import { home, stateRoot } from '../paths.js';
|
|
8
|
+
const VERSION = 2;
|
|
9
|
+
export const WEB_SYSTEMD_UNIT = 'ours-fleet-web.service';
|
|
10
|
+
export const WEB_LAUNCHD_LABEL = 'network.ours.fleet.web';
|
|
11
|
+
export class WebServiceManager {
|
|
12
|
+
platform;
|
|
13
|
+
exec;
|
|
14
|
+
homeDir;
|
|
15
|
+
stateDir;
|
|
16
|
+
uid;
|
|
17
|
+
runtimeExecutable;
|
|
18
|
+
constructor(options = {}) {
|
|
19
|
+
const platform = options.platform ?? process.platform;
|
|
20
|
+
if (platform !== 'linux' && platform !== 'darwin')
|
|
21
|
+
throw new FleetError('capability_unavailable', `web service is unsupported on ${platform}`);
|
|
22
|
+
this.platform = platform;
|
|
23
|
+
this.exec = options.exec ?? realExec;
|
|
24
|
+
this.homeDir = options.homeDir ?? home();
|
|
25
|
+
this.stateDir = options.stateDir ?? join(stateRoot(), 'web');
|
|
26
|
+
this.uid = options.uid ?? process.getuid?.() ?? 501;
|
|
27
|
+
this.runtimeExecutable = options.runtimeExecutable ?? process.execPath;
|
|
28
|
+
}
|
|
29
|
+
get metadataPath() { return join(this.stateDir, 'service.json'); }
|
|
30
|
+
get definitionPath() {
|
|
31
|
+
return this.platform === 'linux'
|
|
32
|
+
? join(this.homeDir, '.config', 'systemd', 'user', WEB_SYSTEMD_UNIT)
|
|
33
|
+
: join(this.homeDir, 'Library', 'LaunchAgents', `${WEB_LAUNCHD_LABEL}.plist`);
|
|
34
|
+
}
|
|
35
|
+
async install(script, port = 49_271, configuration) {
|
|
36
|
+
validatePort(port);
|
|
37
|
+
const resolvedScript = resolveExecutable(script, 'web CLI script');
|
|
38
|
+
const runtime = resolveExecutable(this.runtimeExecutable, 'Node runtime');
|
|
39
|
+
mkdirSync(dirname(this.definitionPath), { recursive: true, mode: 0o700 });
|
|
40
|
+
mkdirSync(this.stateDir, { recursive: true, mode: 0o700 });
|
|
41
|
+
chmodSync(this.stateDir, 0o700);
|
|
42
|
+
const config = configuration ? resolve(configuration) : undefined;
|
|
43
|
+
const metadata = {
|
|
44
|
+
version: VERSION, platform: this.platform, runtime, script: resolvedScript, port,
|
|
45
|
+
...(config ? { configuration: config } : {}),
|
|
46
|
+
};
|
|
47
|
+
replaceFileAtomically(this.definitionPath, this.platform === 'linux'
|
|
48
|
+
? systemdUnit(runtime, resolvedScript, port, config)
|
|
49
|
+
: launchdPlist(runtime, resolvedScript, port, config), 0o600);
|
|
50
|
+
replaceFileAtomically(this.metadataPath, JSON.stringify(metadata, null, 2) + '\n', 0o600);
|
|
51
|
+
if (this.platform === 'linux') {
|
|
52
|
+
await this.must('systemctl', ['--user', 'daemon-reload']);
|
|
53
|
+
await this.must('systemctl', ['--user', 'enable', WEB_SYSTEMD_UNIT]);
|
|
54
|
+
const linger = await this.exec('loginctl', ['show-user', userInfo().username, '-p', 'Linger', '--value']);
|
|
55
|
+
return [
|
|
56
|
+
`installed ${this.definitionPath}`,
|
|
57
|
+
linger.stdout.trim() === 'yes'
|
|
58
|
+
? 'login persistence available (linger enabled)'
|
|
59
|
+
: `warning: login persistence requires linger; run: sudo loginctl enable-linger ${userInfo().username}`,
|
|
60
|
+
];
|
|
61
|
+
}
|
|
62
|
+
return [`installed ${this.definitionPath}`, 'launchd service starts at login'];
|
|
63
|
+
}
|
|
64
|
+
async start() {
|
|
65
|
+
this.requireInstalled();
|
|
66
|
+
if (this.platform === 'linux') {
|
|
67
|
+
await this.must('systemctl', ['--user', 'start', WEB_SYSTEMD_UNIT]);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
const domain = `gui/${this.uid}`;
|
|
71
|
+
const loaded = await this.exec('launchctl', ['print', `${domain}/${WEB_LAUNCHD_LABEL}`]);
|
|
72
|
+
if (loaded.code === 0)
|
|
73
|
+
await this.must('launchctl', ['kickstart', `${domain}/${WEB_LAUNCHD_LABEL}`]);
|
|
74
|
+
else
|
|
75
|
+
await this.must('launchctl', ['bootstrap', domain, this.definitionPath]);
|
|
76
|
+
}
|
|
77
|
+
async stop() {
|
|
78
|
+
if (this.platform === 'linux') {
|
|
79
|
+
await this.must('systemctl', ['--user', 'stop', WEB_SYSTEMD_UNIT]);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
const result = await this.exec('launchctl', ['bootout', `gui/${this.uid}/${WEB_LAUNCHD_LABEL}`]);
|
|
83
|
+
if (result.code !== 0 && !/could not find service|no such process/i.test(`${result.stdout}\n${result.stderr}`))
|
|
84
|
+
throw new FleetError('control_unavailable', `launchctl bootout failed: ${result.stderr.trim()}`);
|
|
85
|
+
}
|
|
86
|
+
async restart() {
|
|
87
|
+
this.requireInstalled();
|
|
88
|
+
if (this.platform === 'linux') {
|
|
89
|
+
await this.must('systemctl', ['--user', 'restart', WEB_SYSTEMD_UNIT]);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
const result = await this.exec('launchctl', ['kickstart', '-k', `gui/${this.uid}/${WEB_LAUNCHD_LABEL}`]);
|
|
93
|
+
if (result.code !== 0) {
|
|
94
|
+
await this.stop();
|
|
95
|
+
await this.start();
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
async status() {
|
|
99
|
+
if (this.platform === 'linux') {
|
|
100
|
+
const result = await this.exec('systemctl', [
|
|
101
|
+
'--user', 'show', WEB_SYSTEMD_UNIT, '-p', 'LoadState', '-p', 'ActiveState',
|
|
102
|
+
'-p', 'SubState', '-p', 'ExecMainPID', '--no-pager',
|
|
103
|
+
]);
|
|
104
|
+
return result.stdout.trim() || result.stderr.trim() || `exit ${result.code}`;
|
|
105
|
+
}
|
|
106
|
+
const result = await this.exec('launchctl', ['print', `gui/${this.uid}/${WEB_LAUNCHD_LABEL}`]);
|
|
107
|
+
return result.code === 0 ? result.stdout.trim() : `not loaded (${WEB_LAUNCHD_LABEL})`;
|
|
108
|
+
}
|
|
109
|
+
async uninstall() {
|
|
110
|
+
if (this.platform === 'linux') {
|
|
111
|
+
await this.exec('systemctl', ['--user', 'disable', '--now', WEB_SYSTEMD_UNIT]);
|
|
112
|
+
rmSync(this.definitionPath, { force: true });
|
|
113
|
+
rmSync(this.metadataPath, { force: true });
|
|
114
|
+
await this.exec('systemctl', ['--user', 'daemon-reload']);
|
|
115
|
+
}
|
|
116
|
+
else {
|
|
117
|
+
await this.stop();
|
|
118
|
+
rmSync(this.definitionPath, { force: true });
|
|
119
|
+
rmSync(this.metadataPath, { force: true });
|
|
120
|
+
}
|
|
121
|
+
return `uninstalled ${this.platform === 'linux' ? WEB_SYSTEMD_UNIT : WEB_LAUNCHD_LABEL}`;
|
|
122
|
+
}
|
|
123
|
+
readMetadata() {
|
|
124
|
+
try {
|
|
125
|
+
const parsed = JSON.parse(readFileSync(this.metadataPath, 'utf8'));
|
|
126
|
+
return parsed.version === VERSION && parsed.platform === this.platform
|
|
127
|
+
&& typeof parsed.runtime === 'string' && typeof parsed.script === 'string'
|
|
128
|
+
&& Number.isInteger(parsed.port) ? parsed : undefined;
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
return undefined;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
requireInstalled() {
|
|
135
|
+
if (!existsSync(this.definitionPath) || !this.readMetadata())
|
|
136
|
+
throw new FleetError('prerequisite_unavailable', 'web service is not installed; run `ours-fleet web install`');
|
|
137
|
+
}
|
|
138
|
+
async must(command, args) {
|
|
139
|
+
const result = await this.exec(command, args);
|
|
140
|
+
if (result.code !== 0)
|
|
141
|
+
throw new FleetError('control_unavailable', `${command} ${args.join(' ')} failed: ${result.stderr.trim() || `exit ${result.code}`}`);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
function resolveExecutable(executable, label) {
|
|
145
|
+
const absolute = isAbsolute(executable) ? executable : resolve(executable);
|
|
146
|
+
try {
|
|
147
|
+
return realpathSync(absolute);
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
throw new FleetError('prerequisite_unavailable', `${label} does not exist: ${absolute}`);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
function validatePort(port) {
|
|
154
|
+
if (!Number.isInteger(port) || port < 1 || port > 65_535)
|
|
155
|
+
throw new FleetError('invalid_request', 'service port must be between 1 and 65535');
|
|
156
|
+
}
|
|
157
|
+
function systemdQuote(value) {
|
|
158
|
+
return `"${value.replace(/[%\\"]/g, char => char === '%' ? '%%' : `\\${char}`)}"`;
|
|
159
|
+
}
|
|
160
|
+
export function systemdUnit(runtime, script, port, configuration) {
|
|
161
|
+
const config = configuration ? ` --configuration ${systemdQuote(configuration)}` : '';
|
|
162
|
+
return `[Unit]\nDescription=ours-fleet localhost web console\nAfter=default.target\n\n`
|
|
163
|
+
+ `[Service]\nType=simple\nExecStart=${systemdQuote(runtime)} ${systemdQuote(script)} web serve --port ${port} --no-open${config}\n`
|
|
164
|
+
+ `Restart=on-failure\nRestartSec=5\nTimeoutStopSec=15\n\n`
|
|
165
|
+
+ `[Install]\nWantedBy=default.target\n`;
|
|
166
|
+
}
|
|
167
|
+
const xml = (value) => value.replace(/[&<>"']/g, char => ({
|
|
168
|
+
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
|
169
|
+
}[char]));
|
|
170
|
+
export function launchdPlist(runtime, script, port, configuration) {
|
|
171
|
+
const config = configuration
|
|
172
|
+
? `<string>--configuration</string><string>${xml(configuration)}</string>` : '';
|
|
173
|
+
return `<?xml version="1.0" encoding="UTF-8"?>\n`
|
|
174
|
+
+ `<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n`
|
|
175
|
+
+ `<plist version="1.0"><dict>\n<key>Label</key><string>${WEB_LAUNCHD_LABEL}</string>\n`
|
|
176
|
+
+ `<key>ProgramArguments</key><array><string>${xml(runtime)}</string><string>${xml(script)}</string><string>web</string>`
|
|
177
|
+
+ `<string>serve</string><string>--port</string><string>${port}</string><string>--no-open</string>${config}</array>\n`
|
|
178
|
+
+ `<key>RunAtLoad</key><true/><key>KeepAlive</key><dict><key>SuccessfulExit</key><false/></dict>\n`
|
|
179
|
+
+ `<key>ProcessType</key><string>Background</string>\n</dict></plist>\n`;
|
|
180
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { WebSocket } from 'ws';
|
|
2
|
+
import { Tmux } from '../../tmux.js';
|
|
3
|
+
import type { RoleRepository } from '../../application/role-repository.js';
|
|
4
|
+
import type { AuditSink } from '../audit.js';
|
|
5
|
+
type Constructor<T> = new (...args: any[]) => T;
|
|
6
|
+
/** Node exposes these CommonJS xterm packages under `default`; Vite may expose named exports. */
|
|
7
|
+
export declare function resolveModuleConstructor<T>(module: unknown, name: string): Constructor<T>;
|
|
8
|
+
export interface TerminalBridgeManagerOptions {
|
|
9
|
+
repository: RoleRepository;
|
|
10
|
+
audit: AuditSink;
|
|
11
|
+
tmux?: Tmux;
|
|
12
|
+
maxBridges?: number;
|
|
13
|
+
graceMs?: number;
|
|
14
|
+
loadPty?: () => Promise<typeof import('node-pty')>;
|
|
15
|
+
}
|
|
16
|
+
export declare class TerminalBridgeManager {
|
|
17
|
+
private readonly options;
|
|
18
|
+
private readonly bridges;
|
|
19
|
+
private ptyModule?;
|
|
20
|
+
private ptyError?;
|
|
21
|
+
constructor(options: TerminalBridgeManagerOptions);
|
|
22
|
+
available(): Promise<boolean>;
|
|
23
|
+
diagnostic(): string | undefined;
|
|
24
|
+
connect(socket: WebSocket, roleId: string, hello: Record<string, unknown>): Promise<void>;
|
|
25
|
+
close(): Promise<void>;
|
|
26
|
+
}
|
|
27
|
+
export {};
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { Tmux, tmuxArgs } from '../../tmux.js';
|
|
3
|
+
import { FleetError } from '../../application/errors.js';
|
|
4
|
+
const OUTPUT = 0x01;
|
|
5
|
+
const INPUT = 0x02;
|
|
6
|
+
const MAX_INPUT = 8 * 1024;
|
|
7
|
+
const RING_BYTES = 2 * 1024 * 1024;
|
|
8
|
+
const RING_FRAMES = 10_000;
|
|
9
|
+
const LEASE_MS = 30_000;
|
|
10
|
+
/** Node exposes these CommonJS xterm packages under `default`; Vite may expose named exports. */
|
|
11
|
+
export function resolveModuleConstructor(module, name) {
|
|
12
|
+
const record = module && typeof module === 'object' ? module : {};
|
|
13
|
+
const fallback = record.default && typeof record.default === 'object'
|
|
14
|
+
? record.default : {};
|
|
15
|
+
const value = record[name] ?? fallback[name];
|
|
16
|
+
if (typeof value !== 'function')
|
|
17
|
+
throw new FleetError('capability_unavailable', `${name} constructor is unavailable`);
|
|
18
|
+
return value;
|
|
19
|
+
}
|
|
20
|
+
export class TerminalBridgeManager {
|
|
21
|
+
options;
|
|
22
|
+
bridges = new Map();
|
|
23
|
+
ptyModule;
|
|
24
|
+
ptyError;
|
|
25
|
+
constructor(options) {
|
|
26
|
+
this.options = options;
|
|
27
|
+
}
|
|
28
|
+
async available() {
|
|
29
|
+
if (this.ptyModule)
|
|
30
|
+
return true;
|
|
31
|
+
if (this.ptyError)
|
|
32
|
+
return false;
|
|
33
|
+
try {
|
|
34
|
+
this.ptyModule = await (this.options.loadPty?.() ?? import('node-pty'));
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
catch (error) {
|
|
38
|
+
this.ptyError = error.message;
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
diagnostic() { return this.ptyError; }
|
|
43
|
+
async connect(socket, roleId, hello) {
|
|
44
|
+
const role = await this.options.repository.get(roleId);
|
|
45
|
+
if (!role)
|
|
46
|
+
throw new FleetError('role_not_found', `no such role '${roleId}'`);
|
|
47
|
+
if (role.configuredBackend !== 'tmux' && role.detectedBackend !== 'tmux')
|
|
48
|
+
throw new FleetError('capability_unavailable', 'terminal is only available for tmux roles');
|
|
49
|
+
if (!await this.available())
|
|
50
|
+
throw new FleetError('capability_unavailable', `node-pty unavailable: ${this.ptyError ?? 'not installed'}`);
|
|
51
|
+
let bridge = this.bridges.get(roleId);
|
|
52
|
+
if (!bridge) {
|
|
53
|
+
if (this.bridges.size >= (this.options.maxBridges ?? 12))
|
|
54
|
+
throw new FleetError('rate_limited', 'terminal bridge limit reached');
|
|
55
|
+
const tmux = this.options.tmux ?? new Tmux();
|
|
56
|
+
if (!await tmux.has(roleId))
|
|
57
|
+
throw new FleetError('offline', `tmux session '${roleId}' is offline`, { provesOffline: true });
|
|
58
|
+
bridge = await TerminalBridge.create(roleId, tmux, this.ptyModule, this.options.audit, () => this.bridges.delete(roleId), this.options.graceMs ?? 30_000);
|
|
59
|
+
this.bridges.set(roleId, bridge);
|
|
60
|
+
}
|
|
61
|
+
bridge.add(socket, hello);
|
|
62
|
+
}
|
|
63
|
+
async close() {
|
|
64
|
+
for (const bridge of this.bridges.values())
|
|
65
|
+
bridge.dispose('server_shutdown');
|
|
66
|
+
this.bridges.clear();
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
class TerminalBridge {
|
|
70
|
+
roleId;
|
|
71
|
+
pty;
|
|
72
|
+
projection;
|
|
73
|
+
audit;
|
|
74
|
+
onDisposed;
|
|
75
|
+
graceMs;
|
|
76
|
+
id = randomUUID();
|
|
77
|
+
viewers = new Map();
|
|
78
|
+
ring = [];
|
|
79
|
+
ringBytes = 0;
|
|
80
|
+
seq = 0n;
|
|
81
|
+
cols = 120;
|
|
82
|
+
rows = 36;
|
|
83
|
+
lease;
|
|
84
|
+
cleanupTimer;
|
|
85
|
+
disposed = false;
|
|
86
|
+
constructor(roleId, pty, projection, audit, onDisposed, graceMs) {
|
|
87
|
+
this.roleId = roleId;
|
|
88
|
+
this.pty = pty;
|
|
89
|
+
this.projection = projection;
|
|
90
|
+
this.audit = audit;
|
|
91
|
+
this.onDisposed = onDisposed;
|
|
92
|
+
this.graceMs = graceMs;
|
|
93
|
+
}
|
|
94
|
+
static async create(roleId, tmux, ptyModule, audit, onDisposed, graceMs) {
|
|
95
|
+
const [headless, serialization] = await Promise.all([
|
|
96
|
+
import('@xterm/headless'), import('@xterm/addon-serialize'),
|
|
97
|
+
]);
|
|
98
|
+
const Terminal = resolveModuleConstructor(headless, 'Terminal');
|
|
99
|
+
const SerializeAddon = resolveModuleConstructor(serialization, 'SerializeAddon');
|
|
100
|
+
const terminal = new Terminal({ cols: 120, rows: 36, scrollback: 5_000, allowProposedApi: true });
|
|
101
|
+
const serialize = new SerializeAddon();
|
|
102
|
+
terminal.loadAddon(serialize);
|
|
103
|
+
const history = await tmux.captureHistory(roleId, 5_000).catch(() => '');
|
|
104
|
+
if (history)
|
|
105
|
+
await new Promise(resolve => terminal.write(history, resolve));
|
|
106
|
+
const env = Object.fromEntries(['PATH', 'HOME', 'LANG', 'LC_ALL'].flatMap(key => process.env[key] ? [[key, process.env[key]]] : []));
|
|
107
|
+
const pty = ptyModule.spawn('tmux', tmuxArgs(roleId, ['attach-session', '-t', roleId]), {
|
|
108
|
+
name: 'xterm-256color', cols: 120, rows: 36,
|
|
109
|
+
cwd: process.env.HOME ?? process.cwd(), env: { ...env, TERM: 'xterm-256color' },
|
|
110
|
+
});
|
|
111
|
+
const projection = {
|
|
112
|
+
write: (data, callback) => terminal.write(data, callback),
|
|
113
|
+
resize: (cols, rows) => terminal.resize(cols, rows),
|
|
114
|
+
dispose: () => { serialize.dispose(); terminal.dispose(); },
|
|
115
|
+
serialize: () => serialize.serialize(),
|
|
116
|
+
};
|
|
117
|
+
const bridge = new TerminalBridge(roleId, pty, projection, audit, onDisposed, graceMs);
|
|
118
|
+
pty.onData(data => bridge.output(new TextEncoder().encode(data)));
|
|
119
|
+
pty.onExit(() => bridge.dispose('tmux_client_exit'));
|
|
120
|
+
await audit.record({ roleId, action: 'terminal.bridge_open', result: 'succeeded' });
|
|
121
|
+
return bridge;
|
|
122
|
+
}
|
|
123
|
+
add(socket, hello) {
|
|
124
|
+
if (this.disposed)
|
|
125
|
+
throw new FleetError('stale_state', 'terminal bridge exited');
|
|
126
|
+
if (this.cleanupTimer)
|
|
127
|
+
clearTimeout(this.cleanupTimer);
|
|
128
|
+
const viewer = {
|
|
129
|
+
socket, sessionLabel: randomUUID().slice(0, 8), resyncPending: false,
|
|
130
|
+
tokens: 32 * 1024, tokenAt: Date.now(),
|
|
131
|
+
};
|
|
132
|
+
this.viewers.set(socket, viewer);
|
|
133
|
+
const lastSeq = parseSeq(hello.lastSeq);
|
|
134
|
+
const bridgeMatches = hello.bridgeId === this.id;
|
|
135
|
+
const first = this.ring[0]?.seq ?? this.seq;
|
|
136
|
+
if (bridgeMatches && lastSeq !== undefined && lastSeq >= first - 1n && lastSeq <= this.seq) {
|
|
137
|
+
for (const frame of this.ring)
|
|
138
|
+
if (frame.seq > lastSeq)
|
|
139
|
+
socket.send(binaryFrame(frame));
|
|
140
|
+
}
|
|
141
|
+
else {
|
|
142
|
+
socket.send(JSON.stringify({
|
|
143
|
+
type: 'snapshot', atSeq: this.seq.toString(), encoding: 'utf8',
|
|
144
|
+
data: this.projection.serialize(),
|
|
145
|
+
}));
|
|
146
|
+
}
|
|
147
|
+
socket.send(JSON.stringify({
|
|
148
|
+
type: 'ready', bridgeId: this.id, firstSeq: first.toString(), lastSeq: this.seq.toString(),
|
|
149
|
+
mode: this.lease?.socket === socket ? 'controller' : 'viewer',
|
|
150
|
+
leaseExpiresAt: this.lease ? new Date(this.lease.expiresAt).toISOString() : null,
|
|
151
|
+
}));
|
|
152
|
+
socket.on('message', (data, binary) => this.message(viewer, data, binary));
|
|
153
|
+
socket.on('close', () => this.remove(socket));
|
|
154
|
+
void this.audit.record({ roleId: this.roleId, action: 'terminal.viewer_open', result: 'succeeded' });
|
|
155
|
+
}
|
|
156
|
+
output(bytes) {
|
|
157
|
+
if (this.disposed || bytes.byteLength === 0)
|
|
158
|
+
return;
|
|
159
|
+
this.seq++;
|
|
160
|
+
const frame = { seq: this.seq, bytes };
|
|
161
|
+
this.ring.push(frame);
|
|
162
|
+
this.ringBytes += bytes.byteLength;
|
|
163
|
+
while (this.ring.length > RING_FRAMES || this.ringBytes > RING_BYTES) {
|
|
164
|
+
this.ringBytes -= this.ring.shift().bytes.byteLength;
|
|
165
|
+
}
|
|
166
|
+
this.projection.write(bytes);
|
|
167
|
+
const payload = binaryFrame(frame);
|
|
168
|
+
for (const viewer of this.viewers.values()) {
|
|
169
|
+
if (viewer.socket.bufferedAmount > 4 * 1024 * 1024) {
|
|
170
|
+
viewer.socket.close(4408, 'slow consumer');
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
if (viewer.socket.bufferedAmount > 1024 * 1024) {
|
|
174
|
+
viewer.resyncPending = true;
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
if (viewer.resyncPending) {
|
|
178
|
+
viewer.socket.send(JSON.stringify({ type: 'resync.required', reason: 'slow_consumer' }));
|
|
179
|
+
viewer.resyncPending = false;
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
viewer.socket.send(payload);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
message(viewer, data, binary) {
|
|
186
|
+
try {
|
|
187
|
+
if (binary) {
|
|
188
|
+
const bytes = new Uint8Array(data);
|
|
189
|
+
if (bytes.byteLength < 9 || bytes[0] !== INPUT || bytes.byteLength - 9 > MAX_INPUT)
|
|
190
|
+
throw new FleetError('invalid_request', 'invalid terminal input frame');
|
|
191
|
+
if (!this.validLease(viewer.socket))
|
|
192
|
+
throw new FleetError('forbidden', 'writer lease required');
|
|
193
|
+
const input = bytes.slice(9);
|
|
194
|
+
if (!this.consume(viewer, input.byteLength))
|
|
195
|
+
throw new FleetError('rate_limited', 'terminal input rate exceeded');
|
|
196
|
+
this.pty.write(new TextDecoder().decode(input));
|
|
197
|
+
this.renewLease();
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
const message = JSON.parse(data.toString());
|
|
201
|
+
if (message.type === 'lease.request')
|
|
202
|
+
this.requestLease(viewer);
|
|
203
|
+
else if (message.type === 'lease.release')
|
|
204
|
+
this.releaseLease(viewer.socket);
|
|
205
|
+
else if (message.type === 'resize')
|
|
206
|
+
this.resize(viewer, message);
|
|
207
|
+
else
|
|
208
|
+
throw new FleetError('invalid_request', 'unknown terminal control message');
|
|
209
|
+
}
|
|
210
|
+
catch (error) {
|
|
211
|
+
const fleet = error instanceof FleetError ? error : new FleetError('invalid_request', error.message);
|
|
212
|
+
viewer.socket.send(JSON.stringify({ type: 'error', code: fleet.code, message: fleet.message }));
|
|
213
|
+
if (fleet.code === 'rate_limited')
|
|
214
|
+
viewer.socket.close(4408, fleet.message);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
requestLease(viewer) {
|
|
218
|
+
const now = Date.now();
|
|
219
|
+
if (this.lease && this.lease.expiresAt > now && this.lease.socket !== viewer.socket) {
|
|
220
|
+
viewer.socket.send(JSON.stringify({
|
|
221
|
+
type: 'error', code: 'lease_held',
|
|
222
|
+
message: `controlled by viewer ${this.viewers.get(this.lease.socket)?.sessionLabel ?? 'unknown'}`,
|
|
223
|
+
}));
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
this.lease = { socket: viewer.socket, id: randomUUID(), expiresAt: now + LEASE_MS };
|
|
227
|
+
viewer.socket.send(JSON.stringify({
|
|
228
|
+
type: 'lease.granted', leaseId: this.lease.id,
|
|
229
|
+
leaseExpiresAt: new Date(this.lease.expiresAt).toISOString(),
|
|
230
|
+
}));
|
|
231
|
+
void this.audit.record({ roleId: this.roleId, action: 'terminal.lease_acquire', result: 'succeeded' });
|
|
232
|
+
}
|
|
233
|
+
releaseLease(socket) {
|
|
234
|
+
if (this.lease?.socket !== socket)
|
|
235
|
+
return;
|
|
236
|
+
this.lease = undefined;
|
|
237
|
+
socket.send(JSON.stringify({ type: 'lease.released' }));
|
|
238
|
+
void this.audit.record({ roleId: this.roleId, action: 'terminal.lease_release', result: 'succeeded' });
|
|
239
|
+
}
|
|
240
|
+
resize(viewer, message) {
|
|
241
|
+
if (!this.validLease(viewer.socket) || message.leaseId !== this.lease?.id)
|
|
242
|
+
throw new FleetError('forbidden', 'writer lease required');
|
|
243
|
+
const cols = clamp(Number(message.cols), 80, 240);
|
|
244
|
+
const rows = clamp(Number(message.rows), 24, 80);
|
|
245
|
+
this.cols = cols;
|
|
246
|
+
this.rows = rows;
|
|
247
|
+
this.pty.resize(cols, rows);
|
|
248
|
+
this.projection.resize(cols, rows);
|
|
249
|
+
this.renewLease();
|
|
250
|
+
}
|
|
251
|
+
validLease(socket) {
|
|
252
|
+
if (this.lease && this.lease.expiresAt <= Date.now())
|
|
253
|
+
this.lease = undefined;
|
|
254
|
+
return this.lease?.socket === socket;
|
|
255
|
+
}
|
|
256
|
+
renewLease() {
|
|
257
|
+
if (this.lease)
|
|
258
|
+
this.lease.expiresAt = Date.now() + LEASE_MS;
|
|
259
|
+
}
|
|
260
|
+
consume(viewer, amount) {
|
|
261
|
+
const now = Date.now();
|
|
262
|
+
viewer.tokens = Math.min(32 * 1024, viewer.tokens + (now - viewer.tokenAt) * 16);
|
|
263
|
+
viewer.tokenAt = now;
|
|
264
|
+
if (amount > viewer.tokens)
|
|
265
|
+
return false;
|
|
266
|
+
viewer.tokens -= amount;
|
|
267
|
+
return true;
|
|
268
|
+
}
|
|
269
|
+
remove(socket) {
|
|
270
|
+
this.releaseLease(socket);
|
|
271
|
+
this.viewers.delete(socket);
|
|
272
|
+
void this.audit.record({ roleId: this.roleId, action: 'terminal.viewer_close', result: 'succeeded' });
|
|
273
|
+
if (!this.viewers.size)
|
|
274
|
+
this.cleanupTimer = setTimeout(() => this.dispose('idle_grace_expired'), this.graceMs);
|
|
275
|
+
}
|
|
276
|
+
dispose(reason) {
|
|
277
|
+
if (this.disposed)
|
|
278
|
+
return;
|
|
279
|
+
this.disposed = true;
|
|
280
|
+
if (this.cleanupTimer)
|
|
281
|
+
clearTimeout(this.cleanupTimer);
|
|
282
|
+
for (const viewer of this.viewers.values()) {
|
|
283
|
+
if (viewer.socket.readyState === viewer.socket.OPEN) {
|
|
284
|
+
viewer.socket.send(JSON.stringify({ type: 'terminal.exit', reason }));
|
|
285
|
+
viewer.socket.close(1001, reason);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
this.viewers.clear();
|
|
289
|
+
try {
|
|
290
|
+
this.pty.kill();
|
|
291
|
+
}
|
|
292
|
+
catch { /* already exited */ }
|
|
293
|
+
this.projection.dispose();
|
|
294
|
+
this.onDisposed();
|
|
295
|
+
void this.audit.record({ roleId: this.roleId, action: 'terminal.bridge_close', result: reason });
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
function binaryFrame(frame) {
|
|
299
|
+
const payload = new Uint8Array(9 + frame.bytes.byteLength);
|
|
300
|
+
payload[0] = OUTPUT;
|
|
301
|
+
new DataView(payload.buffer).setBigUint64(1, frame.seq);
|
|
302
|
+
payload.set(frame.bytes, 9);
|
|
303
|
+
return payload;
|
|
304
|
+
}
|
|
305
|
+
function parseSeq(value) {
|
|
306
|
+
try {
|
|
307
|
+
return value === undefined ? undefined : BigInt(String(value));
|
|
308
|
+
}
|
|
309
|
+
catch {
|
|
310
|
+
return undefined;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
function clamp(value, min, max) {
|
|
314
|
+
if (!Number.isFinite(value))
|
|
315
|
+
throw new FleetError('invalid_request', 'terminal size must be numeric');
|
|
316
|
+
return Math.min(Math.max(Math.trunc(value), min), max);
|
|
317
|
+
}
|