@ours.network/fleet 0.10.3 → 0.11.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/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 +42 -0
- package/dist/application/fleet-query-service.js +188 -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 +341 -3
- package/dist/config.d.ts +9 -2
- package/dist/config.js +21 -5
- package/dist/creation.d.ts +11 -0
- package/dist/creation.js +22 -5
- package/dist/docs.d.ts +1 -1
- package/dist/docs.js +56 -0
- package/dist/duration.d.ts +5 -0
- package/dist/duration.js +20 -0
- package/dist/index.d.ts +10 -1
- package/dist/index.js +9 -1
- package/dist/ops.d.ts +16 -0
- package/dist/ops.js +112 -3
- package/dist/paths.d.ts +1 -0
- package/dist/paths.js +1 -0
- package/dist/resolved-plan.js +7 -0
- 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/watchdog/alerts.d.ts +34 -0
- package/dist/watchdog/alerts.js +78 -0
- package/dist/watchdog/briefing.d.ts +65 -0
- package/dist/watchdog/briefing.js +181 -0
- package/dist/watchdog/config.d.ts +49 -0
- package/dist/watchdog/config.js +114 -0
- package/dist/watchdog/query.d.ts +78 -0
- package/dist/watchdog/query.js +124 -0
- package/dist/watchdog/report.d.ts +53 -0
- package/dist/watchdog/report.js +126 -0
- package/dist/watchdog/run.d.ts +61 -0
- package/dist/watchdog/run.js +318 -0
- package/dist/watchdog/scheduler.d.ts +105 -0
- package/dist/watchdog/scheduler.js +244 -0
- package/dist/watchdog/service.d.ts +46 -0
- package/dist/watchdog/service.js +179 -0
- package/dist/watchdog/store.d.ts +85 -0
- package/dist/watchdog/store.js +226 -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 +214 -0
- package/dist/web/server.d.ts +37 -0
- package/dist/web/server.js +279 -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-BvcIkuIF.js +9 -0
- package/dist/web-app/assets/index-B-jtLAkp.css +1 -0
- package/dist/web-app/assets/index-CUN7ksTw.js +9 -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,27 @@
|
|
|
1
|
+
export interface TrustedDeviceIssue {
|
|
2
|
+
token: string;
|
|
3
|
+
id: string;
|
|
4
|
+
expiresAt: number;
|
|
5
|
+
}
|
|
6
|
+
/** Persistent trusted-device registry. Raw device secrets never enter this store. */
|
|
7
|
+
export declare class TrustedDeviceStore {
|
|
8
|
+
private readonly now;
|
|
9
|
+
private readonly ttlMs;
|
|
10
|
+
private readonly maxDevices;
|
|
11
|
+
readonly dir: string;
|
|
12
|
+
readonly path: string;
|
|
13
|
+
private devices;
|
|
14
|
+
constructor(dir?: string, now?: () => number, ttlMs?: number, maxDevices?: number);
|
|
15
|
+
issue(): TrustedDeviceIssue;
|
|
16
|
+
/** Validate and rotate on use. The old token is invalid as soon as this returns. */
|
|
17
|
+
rotate(token: string): TrustedDeviceIssue | undefined;
|
|
18
|
+
revoke(token: string): boolean;
|
|
19
|
+
/** Revoke an already-authenticated device without retaining its raw secret in a session. */
|
|
20
|
+
revokeId(id: string): boolean;
|
|
21
|
+
revokeAll(): number;
|
|
22
|
+
count(): number;
|
|
23
|
+
private read;
|
|
24
|
+
private prune;
|
|
25
|
+
private write;
|
|
26
|
+
}
|
|
27
|
+
export declare const TRUSTED_DEVICE_MAX_AGE_SECONDS: number;
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
import { chmodSync, lstatSync, mkdirSync, readFileSync, } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { replaceFileAtomically } from '../atomic-file.js';
|
|
5
|
+
import { FleetError } from '../application/errors.js';
|
|
6
|
+
import { stateRoot } from '../paths.js';
|
|
7
|
+
const VERSION = 1;
|
|
8
|
+
const DEFAULT_TTL_MS = 30 * 24 * 60 * 60_000;
|
|
9
|
+
const DEFAULT_MAX_DEVICES = 32;
|
|
10
|
+
const MAX_STORE_BYTES = 128 * 1024;
|
|
11
|
+
const opaque = (bytes) => randomBytes(bytes).toString('base64url');
|
|
12
|
+
const digest = (id, secret) => createHash('sha256').update('ours-fleet-device-v1\0').update(id).update('\0').update(secret).digest('base64url');
|
|
13
|
+
const safeEqual = (left, right) => {
|
|
14
|
+
const a = Buffer.from(left);
|
|
15
|
+
const b = Buffer.from(right);
|
|
16
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
17
|
+
};
|
|
18
|
+
function parseToken(token) {
|
|
19
|
+
const split = token.indexOf('.');
|
|
20
|
+
if (split < 1)
|
|
21
|
+
return undefined;
|
|
22
|
+
const id = token.slice(0, split);
|
|
23
|
+
const secret = token.slice(split + 1);
|
|
24
|
+
if (!/^[A-Za-z0-9_-]{16,64}$/.test(id) || !/^[A-Za-z0-9_-]{32,128}$/.test(secret))
|
|
25
|
+
return undefined;
|
|
26
|
+
return { id, secret };
|
|
27
|
+
}
|
|
28
|
+
/** Persistent trusted-device registry. Raw device secrets never enter this store. */
|
|
29
|
+
export class TrustedDeviceStore {
|
|
30
|
+
now;
|
|
31
|
+
ttlMs;
|
|
32
|
+
maxDevices;
|
|
33
|
+
dir;
|
|
34
|
+
path;
|
|
35
|
+
devices;
|
|
36
|
+
constructor(dir = join(stateRoot(), 'web'), now = Date.now, ttlMs = DEFAULT_TTL_MS, maxDevices = DEFAULT_MAX_DEVICES) {
|
|
37
|
+
this.now = now;
|
|
38
|
+
this.ttlMs = ttlMs;
|
|
39
|
+
this.maxDevices = maxDevices;
|
|
40
|
+
this.dir = dir;
|
|
41
|
+
this.path = join(dir, 'trusted-devices.json');
|
|
42
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
43
|
+
chmodSync(dir, 0o700);
|
|
44
|
+
this.devices = this.read();
|
|
45
|
+
this.prune(true);
|
|
46
|
+
}
|
|
47
|
+
issue() {
|
|
48
|
+
this.prune(false);
|
|
49
|
+
const now = this.now();
|
|
50
|
+
const id = opaque(16);
|
|
51
|
+
const secret = opaque(32);
|
|
52
|
+
const device = {
|
|
53
|
+
id, secretHash: digest(id, secret), pairedAt: now,
|
|
54
|
+
lastUsedAt: now, expiresAt: now + this.ttlMs,
|
|
55
|
+
};
|
|
56
|
+
this.devices.push(device);
|
|
57
|
+
this.devices.sort((a, b) => b.lastUsedAt - a.lastUsedAt);
|
|
58
|
+
this.devices = this.devices.slice(0, Math.max(1, Math.min(this.maxDevices, 128)));
|
|
59
|
+
this.write();
|
|
60
|
+
return { token: `${id}.${secret}`, id, expiresAt: device.expiresAt };
|
|
61
|
+
}
|
|
62
|
+
/** Validate and rotate on use. The old token is invalid as soon as this returns. */
|
|
63
|
+
rotate(token) {
|
|
64
|
+
const parsed = parseToken(token);
|
|
65
|
+
if (!parsed)
|
|
66
|
+
return undefined;
|
|
67
|
+
this.prune(false);
|
|
68
|
+
const index = this.devices.findIndex(device => device.id === parsed.id);
|
|
69
|
+
const existing = index >= 0 ? this.devices[index] : undefined;
|
|
70
|
+
if (!existing || !safeEqual(existing.secretHash, digest(parsed.id, parsed.secret)))
|
|
71
|
+
return undefined;
|
|
72
|
+
const now = this.now();
|
|
73
|
+
if (existing.expiresAt <= now) {
|
|
74
|
+
this.devices.splice(index, 1);
|
|
75
|
+
this.write();
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
78
|
+
const secret = opaque(32);
|
|
79
|
+
const replacement = {
|
|
80
|
+
...existing, secretHash: digest(existing.id, secret),
|
|
81
|
+
lastUsedAt: now, expiresAt: now + this.ttlMs,
|
|
82
|
+
};
|
|
83
|
+
this.devices[index] = replacement;
|
|
84
|
+
this.write();
|
|
85
|
+
return { token: `${existing.id}.${secret}`, id: existing.id, expiresAt: replacement.expiresAt };
|
|
86
|
+
}
|
|
87
|
+
revoke(token) {
|
|
88
|
+
const parsed = parseToken(token);
|
|
89
|
+
if (!parsed)
|
|
90
|
+
return false;
|
|
91
|
+
const index = this.devices.findIndex(device => device.id === parsed.id
|
|
92
|
+
&& safeEqual(device.secretHash, digest(parsed.id, parsed.secret)));
|
|
93
|
+
if (index < 0)
|
|
94
|
+
return false;
|
|
95
|
+
this.devices.splice(index, 1);
|
|
96
|
+
this.write();
|
|
97
|
+
return true;
|
|
98
|
+
}
|
|
99
|
+
/** Revoke an already-authenticated device without retaining its raw secret in a session. */
|
|
100
|
+
revokeId(id) {
|
|
101
|
+
const index = this.devices.findIndex(device => device.id === id);
|
|
102
|
+
if (index < 0)
|
|
103
|
+
return false;
|
|
104
|
+
this.devices.splice(index, 1);
|
|
105
|
+
this.write();
|
|
106
|
+
return true;
|
|
107
|
+
}
|
|
108
|
+
revokeAll() {
|
|
109
|
+
const count = this.devices.length;
|
|
110
|
+
this.devices = [];
|
|
111
|
+
this.write();
|
|
112
|
+
return count;
|
|
113
|
+
}
|
|
114
|
+
count() { this.prune(true); return this.devices.length; }
|
|
115
|
+
read() {
|
|
116
|
+
try {
|
|
117
|
+
const stat = lstatSync(this.path);
|
|
118
|
+
if (!stat.isFile() || stat.isSymbolicLink())
|
|
119
|
+
throw new FleetError('forbidden', 'trusted-device store is not a regular private file');
|
|
120
|
+
if (stat.size > MAX_STORE_BYTES)
|
|
121
|
+
throw new FleetError('forbidden', 'trusted-device store is oversized');
|
|
122
|
+
chmodSync(this.path, 0o600);
|
|
123
|
+
const parsed = JSON.parse(readFileSync(this.path, 'utf8'));
|
|
124
|
+
if (parsed.version !== VERSION || !Array.isArray(parsed.devices))
|
|
125
|
+
return [];
|
|
126
|
+
return parsed.devices.slice(0, 128).filter((device) => Boolean(device && typeof device.id === 'string' && /^[A-Za-z0-9_-]{16,64}$/.test(device.id)
|
|
127
|
+
&& typeof device.secretHash === 'string' && /^[A-Za-z0-9_-]{32,128}$/.test(device.secretHash)
|
|
128
|
+
&& Number.isFinite(device.pairedAt) && Number.isFinite(device.lastUsedAt)
|
|
129
|
+
&& Number.isFinite(device.expiresAt)));
|
|
130
|
+
}
|
|
131
|
+
catch (error) {
|
|
132
|
+
if (error instanceof FleetError)
|
|
133
|
+
throw error;
|
|
134
|
+
if (error.code === 'ENOENT')
|
|
135
|
+
return [];
|
|
136
|
+
// Corrupt content fails closed. A later explicit pairing atomically
|
|
137
|
+
// replaces it; no credential is recovered or guessed.
|
|
138
|
+
return [];
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
prune(persist) {
|
|
142
|
+
const now = this.now();
|
|
143
|
+
const before = this.devices.length;
|
|
144
|
+
this.devices = this.devices.filter(device => device.expiresAt > now)
|
|
145
|
+
.sort((a, b) => b.lastUsedAt - a.lastUsedAt)
|
|
146
|
+
.slice(0, Math.max(1, Math.min(this.maxDevices, 128)));
|
|
147
|
+
if (persist && before !== this.devices.length)
|
|
148
|
+
this.write();
|
|
149
|
+
}
|
|
150
|
+
write() {
|
|
151
|
+
replaceFileAtomically(this.path, JSON.stringify({ version: VERSION, devices: this.devices }, null, 2) + '\n', 0o600);
|
|
152
|
+
chmodSync(this.path, 0o600);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
export const TRUSTED_DEVICE_MAX_AGE_SECONDS = DEFAULT_TTL_MS / 1000;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { WebSocket } from 'ws';
|
|
2
|
+
export interface FleetEvent {
|
|
3
|
+
eventId: string;
|
|
4
|
+
at: string;
|
|
5
|
+
roleId?: string;
|
|
6
|
+
kind: string;
|
|
7
|
+
payload: unknown;
|
|
8
|
+
}
|
|
9
|
+
export declare class FleetEventBus {
|
|
10
|
+
private readonly events;
|
|
11
|
+
private readonly clients;
|
|
12
|
+
publish(kind: string, payload: unknown, roleId?: string): FleetEvent;
|
|
13
|
+
attach(socket: WebSocket, lastEventId?: string): () => void;
|
|
14
|
+
close(): void;
|
|
15
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
export class FleetEventBus {
|
|
3
|
+
events = [];
|
|
4
|
+
clients = new Set();
|
|
5
|
+
publish(kind, payload, roleId) {
|
|
6
|
+
const event = { eventId: randomUUID(), at: new Date().toISOString(), roleId, kind, payload };
|
|
7
|
+
this.events.push(event);
|
|
8
|
+
if (this.events.length > 5_000)
|
|
9
|
+
this.events.shift();
|
|
10
|
+
const frame = JSON.stringify(event);
|
|
11
|
+
for (const socket of this.clients) {
|
|
12
|
+
if (socket.readyState === socket.OPEN && socket.bufferedAmount < 1024 * 1024)
|
|
13
|
+
socket.send(frame);
|
|
14
|
+
}
|
|
15
|
+
return event;
|
|
16
|
+
}
|
|
17
|
+
attach(socket, lastEventId) {
|
|
18
|
+
this.clients.add(socket);
|
|
19
|
+
if (lastEventId) {
|
|
20
|
+
const index = this.events.findIndex(event => event.eventId === lastEventId);
|
|
21
|
+
if (index < 0)
|
|
22
|
+
socket.send(JSON.stringify({ kind: 'resync.required', at: new Date().toISOString() }));
|
|
23
|
+
else
|
|
24
|
+
for (const event of this.events.slice(index + 1))
|
|
25
|
+
socket.send(JSON.stringify(event));
|
|
26
|
+
}
|
|
27
|
+
return () => this.clients.delete(socket);
|
|
28
|
+
}
|
|
29
|
+
close() {
|
|
30
|
+
for (const socket of this.clients)
|
|
31
|
+
socket.close(1001, 'server shutdown');
|
|
32
|
+
this.clients.clear();
|
|
33
|
+
}
|
|
34
|
+
}
|
package/dist/web/lock.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { closeSync, mkdirSync, openSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { stateRoot } from '../paths.js';
|
|
4
|
+
import { FleetError } from '../application/errors.js';
|
|
5
|
+
const processMarker = (pid) => {
|
|
6
|
+
try {
|
|
7
|
+
const stat = readFileSync(`/proc/${pid}/stat`, 'utf8');
|
|
8
|
+
const end = stat.lastIndexOf(')');
|
|
9
|
+
return stat.slice(end + 2).split(/\s+/)[19];
|
|
10
|
+
}
|
|
11
|
+
catch {
|
|
12
|
+
return undefined;
|
|
13
|
+
}
|
|
14
|
+
};
|
|
15
|
+
const alive = (pid) => {
|
|
16
|
+
try {
|
|
17
|
+
process.kill(pid, 0);
|
|
18
|
+
return true;
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
export function acquireWebServerLock(dir = join(stateRoot(), 'web')) {
|
|
25
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
26
|
+
const path = join(dir, 'server.lock');
|
|
27
|
+
const ours = { pid: process.pid, marker: processMarker(process.pid), createdAt: new Date().toISOString() };
|
|
28
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
29
|
+
try {
|
|
30
|
+
const fd = openSync(path, 'wx', 0o600);
|
|
31
|
+
writeFileSync(fd, JSON.stringify(ours) + '\n');
|
|
32
|
+
closeSync(fd);
|
|
33
|
+
let released = false;
|
|
34
|
+
return {
|
|
35
|
+
path,
|
|
36
|
+
release() {
|
|
37
|
+
if (released)
|
|
38
|
+
return;
|
|
39
|
+
released = true;
|
|
40
|
+
try {
|
|
41
|
+
const current = JSON.parse(readFileSync(path, 'utf8'));
|
|
42
|
+
if (current.pid === ours.pid && current.marker === ours.marker)
|
|
43
|
+
rmSync(path, { force: true });
|
|
44
|
+
}
|
|
45
|
+
catch { /* do not remove a lock we cannot prove is ours */ }
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
if (error.code !== 'EEXIST')
|
|
51
|
+
throw error;
|
|
52
|
+
try {
|
|
53
|
+
const current = JSON.parse(readFileSync(path, 'utf8'));
|
|
54
|
+
if (typeof current.pid === 'number' && alive(current.pid)) {
|
|
55
|
+
const marker = processMarker(current.pid);
|
|
56
|
+
if (!marker || !current.marker || marker === current.marker)
|
|
57
|
+
throw new FleetError('conflict', `another ours-fleet web server is running (pid ${current.pid})`);
|
|
58
|
+
}
|
|
59
|
+
rmSync(path, { force: true });
|
|
60
|
+
}
|
|
61
|
+
catch (readError) {
|
|
62
|
+
if (readError instanceof FleetError)
|
|
63
|
+
throw readError;
|
|
64
|
+
throw new FleetError('conflict', 'web server lock exists but cannot be verified safely');
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
throw new FleetError('conflict', 'could not acquire web server lock');
|
|
69
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { type WebServer } from './server.js';
|
|
2
|
+
export interface StartWebOptions {
|
|
3
|
+
configPath?: string;
|
|
4
|
+
port?: number;
|
|
5
|
+
open?: boolean;
|
|
6
|
+
binPath: string;
|
|
7
|
+
log?(line: string): void;
|
|
8
|
+
}
|
|
9
|
+
export interface RunningWebConsole extends WebServer {
|
|
10
|
+
address: string;
|
|
11
|
+
}
|
|
12
|
+
export declare function startWebConsole(options: StartWebOptions): Promise<RunningWebConsole>;
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { existsSync, realpathSync } from 'node:fs';
|
|
3
|
+
import { resolve } from 'node:path';
|
|
4
|
+
import { loadConfig } from '../config.js';
|
|
5
|
+
import { RoleRepository } from '../application/role-repository.js';
|
|
6
|
+
import { FleetQueryService } from '../application/fleet-query-service.js';
|
|
7
|
+
import { AcpRoleSessionAdapter, TmuxRoleSessionAdapter } from '../application/session-control.js';
|
|
8
|
+
import { StructuredLogService } from '../application/log-service.js';
|
|
9
|
+
import { RoleCommandService } from '../application/role-command-service.js';
|
|
10
|
+
import { RoleCreationService } from '../application/role-creation-service.js';
|
|
11
|
+
import { FleetError } from '../application/errors.js';
|
|
12
|
+
import { controlRequest, controlSocketPath } from '../session/control.js';
|
|
13
|
+
import { Tmux } from '../tmux.js';
|
|
14
|
+
import { pickBackend } from '../supervisor/index.js';
|
|
15
|
+
import { realExec } from '../exec.js';
|
|
16
|
+
import { home, stateRoot } from '../paths.js';
|
|
17
|
+
import { AuditSink } from './audit.js';
|
|
18
|
+
import { FleetEventBus } from './events.js';
|
|
19
|
+
import { buildWebServer } from './server.js';
|
|
20
|
+
import { TerminalBridgeManager } from './terminal/bridge.js';
|
|
21
|
+
import { acquireWebServerLock } from './lock.js';
|
|
22
|
+
import { TrustedDeviceStore } from './device-store.js';
|
|
23
|
+
import { WebAuth } from './auth.js';
|
|
24
|
+
import { startWebControlServer } from './control.js';
|
|
25
|
+
import { buildWatchdogFindings, cachedWatchdogFindingsProvider, WatchdogQueryService } from '../watchdog/query.js';
|
|
26
|
+
import { latestReport } from '../watchdog/store.js';
|
|
27
|
+
const CONFIG_CACHE_TTL_MS = 5_000;
|
|
28
|
+
/**
|
|
29
|
+
* loadConfig re-parses YAML from disk on every call; the watchdog list/reports
|
|
30
|
+
* routes get polled by the console UI, so cache the resolved config for a
|
|
31
|
+
* short TTL rather than re-parsing per request. Runtime-only concern (not the
|
|
32
|
+
* scheduler's), so a plain Date.now() clock is fine.
|
|
33
|
+
*/
|
|
34
|
+
function cachedConfigProvider(configPath) {
|
|
35
|
+
let cached;
|
|
36
|
+
return () => {
|
|
37
|
+
const now = Date.now();
|
|
38
|
+
if (!cached || now - cached.at >= CONFIG_CACHE_TTL_MS)
|
|
39
|
+
cached = { at: now, cfg: loadConfig(configPath) };
|
|
40
|
+
return cached.cfg;
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
export async function startWebConsole(options) {
|
|
44
|
+
const requestedPort = options.port ?? 49_271;
|
|
45
|
+
if (!Number.isInteger(requestedPort) || requestedPort < 0 || requestedPort > 65_535)
|
|
46
|
+
throw new FleetError('invalid_request', 'port must be between 0 and 65535');
|
|
47
|
+
const lock = acquireWebServerLock();
|
|
48
|
+
const webDir = resolve(stateRoot(), 'web');
|
|
49
|
+
const auth = new WebAuth(`http://127.0.0.1:${requestedPort}`, `127.0.0.1:${requestedPort}`, Date.now, new TrustedDeviceStore(webDir));
|
|
50
|
+
const tmux = new Tmux();
|
|
51
|
+
const backend = pickBackend();
|
|
52
|
+
const repository = new RoleRepository({
|
|
53
|
+
configPath: options.configPath,
|
|
54
|
+
probeBackend: async (name) => {
|
|
55
|
+
let acp = false;
|
|
56
|
+
const permanent = resolve(home(), '.ours-fleet', 'agents', name);
|
|
57
|
+
const temporary = resolve(home(), '.ours-fleet', 'tmp', name);
|
|
58
|
+
for (const dir of [permanent, temporary]) {
|
|
59
|
+
if (!existsSync(controlSocketPath(dir)))
|
|
60
|
+
continue;
|
|
61
|
+
try {
|
|
62
|
+
acp = (await controlRequest(dir, { command: 'snapshot' }, 500)).ok;
|
|
63
|
+
}
|
|
64
|
+
catch { /* stale socket is evidence, not reachability */ }
|
|
65
|
+
}
|
|
66
|
+
return { acp, tmux: await tmux.has(name).catch(() => false) };
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
const events = new FleetEventBus();
|
|
70
|
+
const audit = new AuditSink();
|
|
71
|
+
const terminals = new TerminalBridgeManager({ repository, audit, tmux });
|
|
72
|
+
const terminalAvailable = await terminals.available();
|
|
73
|
+
const watchdogConfigProvider = cachedConfigProvider(options.configPath);
|
|
74
|
+
const log = options.log ?? (() => { });
|
|
75
|
+
let loggedWatchdogFindingsError = false;
|
|
76
|
+
// Needs-attention integration (Task 19): worst per-role watchdog finding,
|
|
77
|
+
// rebuilt from stored reports. A store hiccup (corrupt state, unreadable
|
|
78
|
+
// report) must never break the fleet list, so it degrades to an empty map
|
|
79
|
+
// and logs once rather than repeating on every poll. status() calls this
|
|
80
|
+
// once per role, so list()'s O(roles) sweep would otherwise cost
|
|
81
|
+
// O(roles x watchdogs) disk reads every 1-3s of console polling —
|
|
82
|
+
// cachedWatchdogFindingsProvider memoizes the whole build behind the same
|
|
83
|
+
// TTL as the config cache above.
|
|
84
|
+
const watchdogFindings = cachedWatchdogFindingsProvider(() => {
|
|
85
|
+
try {
|
|
86
|
+
return buildWatchdogFindings(watchdogConfigProvider(), latestReport);
|
|
87
|
+
}
|
|
88
|
+
catch (error) {
|
|
89
|
+
if (!loggedWatchdogFindingsError) {
|
|
90
|
+
loggedWatchdogFindingsError = true;
|
|
91
|
+
log(`watchdog findings unavailable: ${error.message}`);
|
|
92
|
+
}
|
|
93
|
+
return new Map();
|
|
94
|
+
}
|
|
95
|
+
}, CONFIG_CACHE_TTL_MS);
|
|
96
|
+
const query = new FleetQueryService({
|
|
97
|
+
repository, supervisor: backend, tmux,
|
|
98
|
+
capabilityContext: { terminalPtyAvailable: terminalAvailable },
|
|
99
|
+
watchdogFindings,
|
|
100
|
+
});
|
|
101
|
+
const ops = { backend, binPath: options.binPath, log };
|
|
102
|
+
const creation = new RoleCreationService({
|
|
103
|
+
configPath: options.configPath, ops, binPath: options.binPath,
|
|
104
|
+
allowedCwdRoots: [realpathSync(home()), realpathSync(process.cwd())],
|
|
105
|
+
probeReady: async (name) => {
|
|
106
|
+
const detail = await query.detail(name).catch(() => undefined);
|
|
107
|
+
return detail?.status.overall === 'ready' || detail?.status.overall === 'busy'
|
|
108
|
+
? 'ready' : detail?.status.overall === 'attention' ? 'attention' : 'unknown';
|
|
109
|
+
},
|
|
110
|
+
onProgress: action => {
|
|
111
|
+
events.publish('creation.changed', action, action.roleId);
|
|
112
|
+
void audit.record({
|
|
113
|
+
roleId: action.roleId, action: `creation.${action.state}`,
|
|
114
|
+
result: action.error?.code ?? action.state,
|
|
115
|
+
});
|
|
116
|
+
},
|
|
117
|
+
});
|
|
118
|
+
const commands = new RoleCommandService({
|
|
119
|
+
repository, ops, configPath: options.configPath,
|
|
120
|
+
status: async (roleId) => (await query.detail(roleId)).status,
|
|
121
|
+
onProgress: receipt => {
|
|
122
|
+
events.publish('action.changed', receipt, receipt.roleId);
|
|
123
|
+
void audit.record({
|
|
124
|
+
roleId: receipt.roleId, action: `lifecycle.${receipt.action}`,
|
|
125
|
+
result: receipt.state, errorCode: receipt.error?.code,
|
|
126
|
+
});
|
|
127
|
+
},
|
|
128
|
+
});
|
|
129
|
+
const logs = new StructuredLogService(backend, realExec);
|
|
130
|
+
const watchdogs = new WatchdogQueryService(watchdogConfigProvider);
|
|
131
|
+
let server;
|
|
132
|
+
try {
|
|
133
|
+
server = await buildWebServer({
|
|
134
|
+
query, repository, logs, commands, creation, audit, events, watchdogs,
|
|
135
|
+
terminalUpgrade: terminalAvailable
|
|
136
|
+
? async (socket, _request, roleId, _ticket, hello) => terminals.connect(socket, roleId, hello)
|
|
137
|
+
: undefined,
|
|
138
|
+
async session(roleId) {
|
|
139
|
+
const role = await repository.get(roleId);
|
|
140
|
+
if (!role)
|
|
141
|
+
throw new FleetError('role_not_found', `no such role '${roleId}'`);
|
|
142
|
+
if (role.configuredBackend === 'acp') {
|
|
143
|
+
const dir = repository.stateDir(role);
|
|
144
|
+
if (!dir)
|
|
145
|
+
throw new FleetError('control_unavailable', 'role state directory is unavailable');
|
|
146
|
+
return new AcpRoleSessionAdapter(dir);
|
|
147
|
+
}
|
|
148
|
+
if (role.configuredBackend === 'tmux' || role.detectedBackend === 'tmux')
|
|
149
|
+
return new TmuxRoleSessionAdapter(roleId, tmux);
|
|
150
|
+
throw new FleetError('capability_unavailable', 'role session backend is unavailable');
|
|
151
|
+
},
|
|
152
|
+
}, { origin: `http://127.0.0.1:${requestedPort}`, host: `127.0.0.1:${requestedPort}` }, { auth });
|
|
153
|
+
}
|
|
154
|
+
catch (error) {
|
|
155
|
+
lock.release();
|
|
156
|
+
throw error;
|
|
157
|
+
}
|
|
158
|
+
let address;
|
|
159
|
+
try {
|
|
160
|
+
address = await server.app.listen({ host: '127.0.0.1', port: requestedPort });
|
|
161
|
+
}
|
|
162
|
+
catch (error) {
|
|
163
|
+
await server.close();
|
|
164
|
+
lock.release();
|
|
165
|
+
throw error;
|
|
166
|
+
}
|
|
167
|
+
const actual = new URL(address);
|
|
168
|
+
if (actual.hostname !== '127.0.0.1') {
|
|
169
|
+
await server.close();
|
|
170
|
+
lock.release();
|
|
171
|
+
throw new FleetError('forbidden', 'web console refused a non-loopback bind');
|
|
172
|
+
}
|
|
173
|
+
const host = `127.0.0.1:${actual.port}`;
|
|
174
|
+
server.auth.setBoundary(`http://${host}`, host);
|
|
175
|
+
let control;
|
|
176
|
+
try {
|
|
177
|
+
control = await startWebControlServer({
|
|
178
|
+
dir: webDir,
|
|
179
|
+
onOpen() {
|
|
180
|
+
const url = `http://${host}/#bootstrap=${server.auth.mintBootstrap()}`;
|
|
181
|
+
openBrowser(url);
|
|
182
|
+
},
|
|
183
|
+
onRevokeAll() { server.auth.revokeAllTrustedDevices(); },
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
catch (error) {
|
|
187
|
+
await server.close();
|
|
188
|
+
lock.release();
|
|
189
|
+
throw error;
|
|
190
|
+
}
|
|
191
|
+
if (options.open !== false)
|
|
192
|
+
openBrowser(`http://${host}/#bootstrap=${server.auth.bootstrapSecret}`);
|
|
193
|
+
return {
|
|
194
|
+
...server, address: `http://${host}`,
|
|
195
|
+
async close() {
|
|
196
|
+
try {
|
|
197
|
+
await control.close();
|
|
198
|
+
await terminals.close();
|
|
199
|
+
await server.close();
|
|
200
|
+
}
|
|
201
|
+
finally {
|
|
202
|
+
lock.release();
|
|
203
|
+
}
|
|
204
|
+
},
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
function openBrowser(url) {
|
|
208
|
+
const command = process.platform === 'darwin' ? 'open'
|
|
209
|
+
: process.platform === 'win32' ? 'cmd' : 'xdg-open';
|
|
210
|
+
const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url];
|
|
211
|
+
const child = spawn(command, args, { stdio: 'ignore', detached: true, shell: false });
|
|
212
|
+
child.on('error', () => undefined);
|
|
213
|
+
child.unref();
|
|
214
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { type FastifyInstance, type FastifyRequest } from 'fastify';
|
|
2
|
+
import type { WebSocket } from 'ws';
|
|
3
|
+
import type { FleetQueryService } from '../application/fleet-query-service.js';
|
|
4
|
+
import type { RoleRepository } from '../application/role-repository.js';
|
|
5
|
+
import type { RoleSessionControl } from '../application/session-control.js';
|
|
6
|
+
import type { StructuredLogService } from '../application/log-service.js';
|
|
7
|
+
import type { RoleCommandService } from '../application/role-command-service.js';
|
|
8
|
+
import type { RoleCreationService } from '../application/role-creation-service.js';
|
|
9
|
+
import type { WatchdogQueryService } from '../watchdog/query.js';
|
|
10
|
+
import { AuditSink } from './audit.js';
|
|
11
|
+
import { WebAuth } from './auth.js';
|
|
12
|
+
import { FleetEventBus } from './events.js';
|
|
13
|
+
export interface WebServices {
|
|
14
|
+
query: FleetQueryService;
|
|
15
|
+
repository: RoleRepository;
|
|
16
|
+
session(roleId: string): Promise<RoleSessionControl>;
|
|
17
|
+
logs: StructuredLogService;
|
|
18
|
+
commands: RoleCommandService;
|
|
19
|
+
creation: RoleCreationService;
|
|
20
|
+
audit?: AuditSink;
|
|
21
|
+
events?: FleetEventBus;
|
|
22
|
+
watchdogs?: WatchdogQueryService;
|
|
23
|
+
terminalUpgrade?: (socket: WebSocket, request: FastifyRequest, roleId: string, ticket: string, hello: Record<string, unknown>) => Promise<void>;
|
|
24
|
+
}
|
|
25
|
+
export interface WebServer {
|
|
26
|
+
app: FastifyInstance;
|
|
27
|
+
auth: WebAuth;
|
|
28
|
+
audit: AuditSink;
|
|
29
|
+
events: FleetEventBus;
|
|
30
|
+
close(): Promise<void>;
|
|
31
|
+
}
|
|
32
|
+
export declare function buildWebServer(services: WebServices, boundary: {
|
|
33
|
+
origin: string;
|
|
34
|
+
host: string;
|
|
35
|
+
}, options?: {
|
|
36
|
+
auth?: WebAuth;
|
|
37
|
+
}): Promise<WebServer>;
|