@owloops/browserbird 1.0.1 → 1.0.3
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/bin/browserbird +7 -1
- package/dist/db-BsYEYsul.mjs +1011 -0
- package/dist/index.mjs +4748 -0
- package/package.json +6 -3
- package/src/channel/blocks.ts +0 -485
- package/src/channel/coalesce.ts +0 -79
- package/src/channel/commands.ts +0 -216
- package/src/channel/handler.ts +0 -272
- package/src/channel/slack.ts +0 -573
- package/src/channel/types.ts +0 -59
- package/src/cli/banner.ts +0 -10
- package/src/cli/birds.ts +0 -396
- package/src/cli/config.ts +0 -77
- package/src/cli/doctor.ts +0 -63
- package/src/cli/index.ts +0 -5
- package/src/cli/jobs.ts +0 -166
- package/src/cli/logs.ts +0 -67
- package/src/cli/run.ts +0 -148
- package/src/cli/sessions.ts +0 -158
- package/src/cli/style.ts +0 -19
- package/src/config.ts +0 -291
- package/src/core/logger.ts +0 -78
- package/src/core/redact.ts +0 -75
- package/src/core/types.ts +0 -83
- package/src/core/uid.ts +0 -26
- package/src/core/utils.ts +0 -137
- package/src/cron/parse.ts +0 -146
- package/src/cron/scheduler.ts +0 -242
- package/src/daemon.ts +0 -169
- package/src/db/auth.ts +0 -49
- package/src/db/birds.ts +0 -357
- package/src/db/core.ts +0 -377
- package/src/db/index.ts +0 -10
- package/src/db/jobs.ts +0 -289
- package/src/db/logs.ts +0 -64
- package/src/db/messages.ts +0 -79
- package/src/db/path.ts +0 -30
- package/src/db/sessions.ts +0 -165
- package/src/jobs.ts +0 -140
- package/src/provider/claude.test.ts +0 -95
- package/src/provider/claude.ts +0 -196
- package/src/provider/opencode.test.ts +0 -169
- package/src/provider/opencode.ts +0 -248
- package/src/provider/session.ts +0 -65
- package/src/provider/spawn.ts +0 -173
- package/src/provider/stream.ts +0 -67
- package/src/provider/types.ts +0 -24
- package/src/server/auth.ts +0 -135
- package/src/server/health.ts +0 -87
- package/src/server/http.ts +0 -132
- package/src/server/index.ts +0 -6
- package/src/server/lifecycle.ts +0 -135
- package/src/server/routes.ts +0 -1199
- package/src/server/sse.ts +0 -54
- package/src/server/static.ts +0 -45
- package/src/server/vnc-proxy.ts +0 -75
package/src/provider/spawn.ts
DELETED
|
@@ -1,173 +0,0 @@
|
|
|
1
|
-
/** @fileoverview Spawn a CLI provider as a subprocess. */
|
|
2
|
-
|
|
3
|
-
import { spawn, type ChildProcess } from 'node:child_process';
|
|
4
|
-
import type { ProviderName, ProviderModule, SpawnOptions } from './types.ts';
|
|
5
|
-
import type { StreamEvent } from './stream.ts';
|
|
6
|
-
import { splitLines } from './stream.ts';
|
|
7
|
-
import { logger } from '../core/logger.ts';
|
|
8
|
-
import { claude } from './claude.ts';
|
|
9
|
-
import { opencode } from './opencode.ts';
|
|
10
|
-
|
|
11
|
-
const SIGKILL_GRACE_MS = 5_000;
|
|
12
|
-
|
|
13
|
-
const PROVIDERS: Record<ProviderName, ProviderModule> = {
|
|
14
|
-
claude,
|
|
15
|
-
opencode,
|
|
16
|
-
};
|
|
17
|
-
|
|
18
|
-
/** Sends SIGTERM, then SIGKILL after a grace period if the process is still alive. */
|
|
19
|
-
function gracefulKill(proc: ChildProcess): void {
|
|
20
|
-
if (!proc.pid || proc.killed) return;
|
|
21
|
-
proc.kill('SIGTERM');
|
|
22
|
-
const escalation = setTimeout(() => {
|
|
23
|
-
if (!proc.killed) {
|
|
24
|
-
logger.warn(`process ${proc.pid} did not exit after SIGTERM, sending SIGKILL`);
|
|
25
|
-
proc.kill('SIGKILL');
|
|
26
|
-
}
|
|
27
|
-
}, SIGKILL_GRACE_MS);
|
|
28
|
-
escalation.unref();
|
|
29
|
-
proc.on('exit', () => clearTimeout(escalation));
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
/** Env vars that prevent nested Claude Code sessions. */
|
|
33
|
-
const STRIPPED_ENV_VARS = ['CLAUDECODE', 'CLAUDE_CODE_ENTRYPOINT'];
|
|
34
|
-
|
|
35
|
-
/** Strips env vars whose names suggest they hold credentials. */
|
|
36
|
-
const SENSITIVE_NAME_RE = /KEY|SECRET|TOKEN|PASSWORD/i;
|
|
37
|
-
|
|
38
|
-
function cleanEnv(): Record<string, string> {
|
|
39
|
-
const env: Record<string, string> = {};
|
|
40
|
-
for (const [key, value] of Object.entries(process.env)) {
|
|
41
|
-
if (value == null) continue;
|
|
42
|
-
if (STRIPPED_ENV_VARS.includes(key)) continue;
|
|
43
|
-
if (SENSITIVE_NAME_RE.test(key)) continue;
|
|
44
|
-
env[key] = value;
|
|
45
|
-
}
|
|
46
|
-
return env;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
interface SpawnResult {
|
|
50
|
-
events: AsyncIterable<StreamEvent>;
|
|
51
|
-
kill: () => void;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
/**
|
|
55
|
-
* Spawns a provider CLI with streaming output.
|
|
56
|
-
* Returns an async iterable of parsed stream events and a kill handle.
|
|
57
|
-
*/
|
|
58
|
-
export function spawnProvider(
|
|
59
|
-
provider: ProviderName,
|
|
60
|
-
options: SpawnOptions,
|
|
61
|
-
signal: AbortSignal,
|
|
62
|
-
): SpawnResult {
|
|
63
|
-
const mod = PROVIDERS[provider];
|
|
64
|
-
const cmd = mod.buildCommand(options);
|
|
65
|
-
const timeoutMs = options.agent.processTimeoutMs ?? 300_000;
|
|
66
|
-
|
|
67
|
-
logger.debug(`spawning: ${cmd.binary} ${cmd.args.join(' ')} (timeout: ${timeoutMs}ms)`);
|
|
68
|
-
|
|
69
|
-
const baseEnv = cleanEnv();
|
|
70
|
-
if (cmd.env) {
|
|
71
|
-
for (const [k, v] of Object.entries(cmd.env)) {
|
|
72
|
-
if (v === '') delete baseEnv[k];
|
|
73
|
-
else baseEnv[k] = v;
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
const proc = spawn(cmd.binary, cmd.args, {
|
|
77
|
-
cwd: cmd.cwd ?? process.cwd(),
|
|
78
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
79
|
-
env: baseEnv,
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
let stderrBuf = '';
|
|
83
|
-
proc.stderr!.on('data', (chunk: Buffer) => {
|
|
84
|
-
stderrBuf += chunk.toString('utf-8');
|
|
85
|
-
});
|
|
86
|
-
|
|
87
|
-
const timeout = setTimeout(() => {
|
|
88
|
-
logger.warn(`${cmd.binary} timed out after ${timeoutMs}ms, killing`);
|
|
89
|
-
gracefulKill(proc);
|
|
90
|
-
}, timeoutMs);
|
|
91
|
-
|
|
92
|
-
const onAbort = () => gracefulKill(proc);
|
|
93
|
-
signal.addEventListener('abort', onAbort, { once: true });
|
|
94
|
-
|
|
95
|
-
async function* iterate(): AsyncIterable<StreamEvent> {
|
|
96
|
-
let buffer = '';
|
|
97
|
-
|
|
98
|
-
try {
|
|
99
|
-
yield* parseStdout(proc, mod, buffer, (b) => {
|
|
100
|
-
buffer = b;
|
|
101
|
-
});
|
|
102
|
-
|
|
103
|
-
if (buffer.trim()) {
|
|
104
|
-
yield* mod.parseStreamLine(buffer);
|
|
105
|
-
}
|
|
106
|
-
} finally {
|
|
107
|
-
clearTimeout(timeout);
|
|
108
|
-
signal.removeEventListener('abort', onAbort);
|
|
109
|
-
if (stderrBuf.trim()) {
|
|
110
|
-
logger.debug(`${cmd.binary} stderr: ${stderrBuf.trim()}`);
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
return {
|
|
116
|
-
events: iterate(),
|
|
117
|
-
kill: () => gracefulKill(proc),
|
|
118
|
-
};
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
async function* parseStdout(
|
|
122
|
-
proc: ChildProcess,
|
|
123
|
-
mod: ProviderModule,
|
|
124
|
-
buffer: string,
|
|
125
|
-
setBuffer: (b: string) => void,
|
|
126
|
-
): AsyncIterable<StreamEvent> {
|
|
127
|
-
const pending: Buffer[] = [];
|
|
128
|
-
let done = false;
|
|
129
|
-
let error: string | null = null;
|
|
130
|
-
let resolve: (() => void) | null = null;
|
|
131
|
-
|
|
132
|
-
proc.stdout!.on('data', (chunk: Buffer) => {
|
|
133
|
-
pending.push(chunk);
|
|
134
|
-
resolve?.();
|
|
135
|
-
});
|
|
136
|
-
|
|
137
|
-
proc.on('error', (err: Error) => {
|
|
138
|
-
error = err.message;
|
|
139
|
-
done = true;
|
|
140
|
-
resolve?.();
|
|
141
|
-
});
|
|
142
|
-
|
|
143
|
-
proc.on('close', () => {
|
|
144
|
-
done = true;
|
|
145
|
-
resolve?.();
|
|
146
|
-
});
|
|
147
|
-
|
|
148
|
-
while (true) {
|
|
149
|
-
while (pending.length === 0 && !done) {
|
|
150
|
-
await new Promise<void>((r) => {
|
|
151
|
-
resolve = r;
|
|
152
|
-
});
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
if (pending.length === 0 && done) break;
|
|
156
|
-
|
|
157
|
-
while (pending.length > 0) {
|
|
158
|
-
const data = pending.shift()!.toString('utf-8');
|
|
159
|
-
logger.debug(`stdout chunk (${data.length} chars)`);
|
|
160
|
-
const [lines, remaining] = splitLines(buffer, data);
|
|
161
|
-
buffer = remaining;
|
|
162
|
-
setBuffer(buffer);
|
|
163
|
-
|
|
164
|
-
for (const line of lines) {
|
|
165
|
-
yield* mod.parseStreamLine(line);
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
if (error) {
|
|
171
|
-
yield { type: 'error', error };
|
|
172
|
-
}
|
|
173
|
-
}
|
package/src/provider/stream.ts
DELETED
|
@@ -1,67 +0,0 @@
|
|
|
1
|
-
/** @fileoverview Shared stream event types and line-splitting utility. */
|
|
2
|
-
|
|
3
|
-
interface StreamEventInit {
|
|
4
|
-
type: 'init';
|
|
5
|
-
sessionId: string;
|
|
6
|
-
model: string;
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
interface StreamEventTextDelta {
|
|
10
|
-
type: 'text_delta';
|
|
11
|
-
delta: string;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
export interface ToolImage {
|
|
15
|
-
mediaType: string;
|
|
16
|
-
data: string;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
interface StreamEventToolImages {
|
|
20
|
-
type: 'tool_images';
|
|
21
|
-
images: ToolImage[];
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
export interface StreamEventCompletion {
|
|
25
|
-
type: 'completion';
|
|
26
|
-
subtype: string;
|
|
27
|
-
result: string;
|
|
28
|
-
sessionId: string;
|
|
29
|
-
isError: boolean;
|
|
30
|
-
tokensIn: number;
|
|
31
|
-
tokensOut: number;
|
|
32
|
-
cacheCreationTokens: number;
|
|
33
|
-
cacheReadTokens: number;
|
|
34
|
-
costUsd: number;
|
|
35
|
-
durationMs: number;
|
|
36
|
-
numTurns: number;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
interface StreamEventRateLimit {
|
|
40
|
-
type: 'rate_limit';
|
|
41
|
-
resetsAt: number;
|
|
42
|
-
status: string;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
interface StreamEventError {
|
|
46
|
-
type: 'error';
|
|
47
|
-
error: string;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
export type StreamEvent =
|
|
51
|
-
| StreamEventInit
|
|
52
|
-
| StreamEventTextDelta
|
|
53
|
-
| StreamEventToolImages
|
|
54
|
-
| StreamEventCompletion
|
|
55
|
-
| StreamEventRateLimit
|
|
56
|
-
| StreamEventError;
|
|
57
|
-
|
|
58
|
-
/**
|
|
59
|
-
* Splits a raw data chunk into lines, handling partial lines across chunks.
|
|
60
|
-
* Returns [completeLines, remainingPartial].
|
|
61
|
-
*/
|
|
62
|
-
export function splitLines(buffer: string, chunk: string): [string[], string] {
|
|
63
|
-
const combined = buffer + chunk;
|
|
64
|
-
const lines = combined.split('\n');
|
|
65
|
-
const partial = lines.pop() ?? '';
|
|
66
|
-
return [lines, partial];
|
|
67
|
-
}
|
package/src/provider/types.ts
DELETED
|
@@ -1,24 +0,0 @@
|
|
|
1
|
-
/** @fileoverview Provider abstraction types for CLI-agnostic subprocess spawning. */
|
|
2
|
-
|
|
3
|
-
import type { StreamEvent } from './stream.ts';
|
|
4
|
-
|
|
5
|
-
export type ProviderName = 'claude' | 'opencode';
|
|
6
|
-
|
|
7
|
-
export interface ProviderCommand {
|
|
8
|
-
binary: string;
|
|
9
|
-
args: string[];
|
|
10
|
-
cwd?: string;
|
|
11
|
-
env?: Record<string, string>;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
export interface SpawnOptions {
|
|
15
|
-
message: string;
|
|
16
|
-
sessionId?: string;
|
|
17
|
-
agent: import('../core/types.ts').AgentConfig;
|
|
18
|
-
mcpConfigPath?: string;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
export interface ProviderModule {
|
|
22
|
-
buildCommand: (options: SpawnOptions) => ProviderCommand;
|
|
23
|
-
parseStreamLine: (line: string) => StreamEvent[];
|
|
24
|
-
}
|
package/src/server/auth.ts
DELETED
|
@@ -1,135 +0,0 @@
|
|
|
1
|
-
/** @fileoverview Password hashing, token signing, and verification using node:crypto. */
|
|
2
|
-
|
|
3
|
-
import { scrypt, randomBytes, timingSafeEqual, createHmac } from 'node:crypto';
|
|
4
|
-
import { getUserById, getSetting, setSetting } from '../db/auth.ts';
|
|
5
|
-
|
|
6
|
-
const SCRYPT_N = 16384;
|
|
7
|
-
const SCRYPT_R = 8;
|
|
8
|
-
const SCRYPT_P = 1;
|
|
9
|
-
const SCRYPT_KEYLEN = 64;
|
|
10
|
-
const SALT_BYTES = 16;
|
|
11
|
-
|
|
12
|
-
const TOKEN_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000;
|
|
13
|
-
|
|
14
|
-
export function hashPassword(password: string): Promise<string> {
|
|
15
|
-
return new Promise((resolve, reject) => {
|
|
16
|
-
const salt = randomBytes(SALT_BYTES);
|
|
17
|
-
scrypt(password, salt, SCRYPT_KEYLEN, { N: SCRYPT_N, r: SCRYPT_R, p: SCRYPT_P }, (err, key) => {
|
|
18
|
-
if (err) {
|
|
19
|
-
reject(err);
|
|
20
|
-
return;
|
|
21
|
-
}
|
|
22
|
-
resolve(
|
|
23
|
-
`scrypt$${SCRYPT_N}$${SCRYPT_R}$${SCRYPT_P}$${salt.toString('hex')}$${key.toString('hex')}`,
|
|
24
|
-
);
|
|
25
|
-
});
|
|
26
|
-
});
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export function verifyPassword(password: string, stored: string): Promise<boolean> {
|
|
30
|
-
return new Promise((resolve, reject) => {
|
|
31
|
-
const parts = stored.split('$');
|
|
32
|
-
if (parts.length !== 6 || parts[0] !== 'scrypt') {
|
|
33
|
-
resolve(false);
|
|
34
|
-
return;
|
|
35
|
-
}
|
|
36
|
-
const n = Number(parts[1]);
|
|
37
|
-
const r = Number(parts[2]);
|
|
38
|
-
const p = Number(parts[3]);
|
|
39
|
-
const salt = Buffer.from(parts[4]!, 'hex');
|
|
40
|
-
const expected = Buffer.from(parts[5]!, 'hex');
|
|
41
|
-
|
|
42
|
-
scrypt(password, salt, expected.length, { N: n, r, p }, (err, key) => {
|
|
43
|
-
if (err) {
|
|
44
|
-
reject(err);
|
|
45
|
-
return;
|
|
46
|
-
}
|
|
47
|
-
resolve(timingSafeEqual(key, expected));
|
|
48
|
-
});
|
|
49
|
-
});
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
export function generateTokenKey(): string {
|
|
53
|
-
return randomBytes(32).toString('hex');
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
export function getOrCreateSecret(): string {
|
|
57
|
-
const existing = getSetting('auth_secret');
|
|
58
|
-
if (existing) return existing;
|
|
59
|
-
const secret = randomBytes(32).toString('hex');
|
|
60
|
-
setSetting('auth_secret', secret);
|
|
61
|
-
return secret;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
function base64UrlEncode(data: string): string {
|
|
65
|
-
return Buffer.from(data).toString('base64url');
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
function base64UrlDecode(encoded: string): string {
|
|
69
|
-
return Buffer.from(encoded, 'base64url').toString();
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
function hmacSign(data: string, key: string): string {
|
|
73
|
-
return createHmac('sha256', key).update(data).digest('base64url');
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
export interface TokenPayload {
|
|
77
|
-
sub: number;
|
|
78
|
-
exp: number;
|
|
79
|
-
iat: number;
|
|
80
|
-
jti: string;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
export function signToken(userId: number, tokenKey: string, secret: string): string {
|
|
84
|
-
const now = Date.now();
|
|
85
|
-
const header = base64UrlEncode(JSON.stringify({ alg: 'HS256', typ: 'JWT' }));
|
|
86
|
-
const payload = base64UrlEncode(
|
|
87
|
-
JSON.stringify({
|
|
88
|
-
sub: userId,
|
|
89
|
-
exp: now + TOKEN_EXPIRY_MS,
|
|
90
|
-
iat: now,
|
|
91
|
-
jti: randomBytes(16).toString('hex'),
|
|
92
|
-
}),
|
|
93
|
-
);
|
|
94
|
-
const signingKey = tokenKey + secret;
|
|
95
|
-
const signature = hmacSign(`${header}.${payload}`, signingKey);
|
|
96
|
-
return `${header}.${payload}.${signature}`;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
/**
|
|
100
|
-
* Verifies a raw token string: structure, signature, expiry, and that the
|
|
101
|
-
* user still exists in the DB. Returns true if valid, false otherwise.
|
|
102
|
-
*/
|
|
103
|
-
export function verifyToken(token: string): boolean {
|
|
104
|
-
const parts = token.split('.');
|
|
105
|
-
if (parts.length !== 3) return false;
|
|
106
|
-
|
|
107
|
-
let sub: number;
|
|
108
|
-
try {
|
|
109
|
-
const decoded = JSON.parse(base64UrlDecode(parts[1]!)) as { sub?: number };
|
|
110
|
-
if (typeof decoded.sub !== 'number') return false;
|
|
111
|
-
sub = decoded.sub;
|
|
112
|
-
} catch {
|
|
113
|
-
return false;
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
const user = getUserById(sub);
|
|
117
|
-
if (!user) return false;
|
|
118
|
-
|
|
119
|
-
const secret = getOrCreateSecret();
|
|
120
|
-
const [header, payload, signature] = parts as [string, string, string];
|
|
121
|
-
const signingKey = user.token_key + secret;
|
|
122
|
-
const expected = hmacSign(`${header}.${payload}`, signingKey);
|
|
123
|
-
|
|
124
|
-
const sigBuf = Buffer.from(signature, 'base64url');
|
|
125
|
-
const expBuf = Buffer.from(expected, 'base64url');
|
|
126
|
-
if (sigBuf.length !== expBuf.length || !timingSafeEqual(sigBuf, expBuf)) return false;
|
|
127
|
-
|
|
128
|
-
try {
|
|
129
|
-
const decoded = JSON.parse(base64UrlDecode(payload)) as TokenPayload;
|
|
130
|
-
if (typeof decoded.exp !== 'number' || decoded.exp < Date.now()) return false;
|
|
131
|
-
return true;
|
|
132
|
-
} catch {
|
|
133
|
-
return false;
|
|
134
|
-
}
|
|
135
|
-
}
|
package/src/server/health.ts
DELETED
|
@@ -1,87 +0,0 @@
|
|
|
1
|
-
/** @fileoverview Cached service health checks for agent CLI and browser connectivity. */
|
|
2
|
-
|
|
3
|
-
import { connect } from 'node:net';
|
|
4
|
-
import type { Config } from '../core/types.ts';
|
|
5
|
-
import { logger } from '../core/logger.ts';
|
|
6
|
-
import { checkDoctor } from '../cli/doctor.ts';
|
|
7
|
-
|
|
8
|
-
export interface ServiceHealth {
|
|
9
|
-
agent: { available: boolean };
|
|
10
|
-
browser: { connected: boolean };
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
const AGENT_CHECK_INTERVAL_MS = 60_000;
|
|
14
|
-
const BROWSER_CHECK_INTERVAL_MS = 5_000;
|
|
15
|
-
const BROWSER_PROBE_TIMEOUT_MS = 2_000;
|
|
16
|
-
|
|
17
|
-
let agentAvailable = false;
|
|
18
|
-
let agentCheckedAt = 0;
|
|
19
|
-
|
|
20
|
-
let browserConnected = false;
|
|
21
|
-
let browserCheckPending = false;
|
|
22
|
-
|
|
23
|
-
function refreshAgent(config: Config): void {
|
|
24
|
-
const now = Date.now();
|
|
25
|
-
if (now - agentCheckedAt < AGENT_CHECK_INTERVAL_MS) return;
|
|
26
|
-
|
|
27
|
-
const result = checkDoctor();
|
|
28
|
-
const usedProviders = new Set(config.agents.map((a) => a.provider));
|
|
29
|
-
agentAvailable = [...usedProviders].some((p) => result[p]?.available === true);
|
|
30
|
-
agentCheckedAt = now;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
export function probeBrowser(host: string, port: number): Promise<boolean> {
|
|
34
|
-
return new Promise((resolve) => {
|
|
35
|
-
const socket = connect(port, host, () => {
|
|
36
|
-
socket.destroy();
|
|
37
|
-
resolve(true);
|
|
38
|
-
});
|
|
39
|
-
socket.setTimeout(BROWSER_PROBE_TIMEOUT_MS);
|
|
40
|
-
socket.on('timeout', () => {
|
|
41
|
-
socket.destroy();
|
|
42
|
-
resolve(false);
|
|
43
|
-
});
|
|
44
|
-
socket.on('error', () => {
|
|
45
|
-
socket.destroy();
|
|
46
|
-
resolve(false);
|
|
47
|
-
});
|
|
48
|
-
});
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
function refreshBrowser(config: Config): void {
|
|
52
|
-
if (!config.browser.enabled || browserCheckPending) return;
|
|
53
|
-
browserCheckPending = true;
|
|
54
|
-
probeBrowser(config.browser.novncHost, config.browser.novncPort)
|
|
55
|
-
.then((ok) => {
|
|
56
|
-
browserConnected = ok;
|
|
57
|
-
})
|
|
58
|
-
.catch(() => {
|
|
59
|
-
browserConnected = false;
|
|
60
|
-
})
|
|
61
|
-
.finally(() => {
|
|
62
|
-
browserCheckPending = false;
|
|
63
|
-
});
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
export function getServiceHealth(config: Config): ServiceHealth {
|
|
67
|
-
refreshAgent(config);
|
|
68
|
-
return {
|
|
69
|
-
agent: { available: agentAvailable },
|
|
70
|
-
browser: { connected: config.browser.enabled ? browserConnected : false },
|
|
71
|
-
};
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
export function startHealthChecks(config: Config, signal: AbortSignal): void {
|
|
75
|
-
refreshAgent(config);
|
|
76
|
-
refreshBrowser(config);
|
|
77
|
-
|
|
78
|
-
const timer = setInterval(() => {
|
|
79
|
-
refreshBrowser(config);
|
|
80
|
-
}, BROWSER_CHECK_INTERVAL_MS);
|
|
81
|
-
|
|
82
|
-
signal.addEventListener('abort', () => {
|
|
83
|
-
clearInterval(timer);
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
logger.debug('health checks started');
|
|
87
|
-
}
|
package/src/server/http.ts
DELETED
|
@@ -1,132 +0,0 @@
|
|
|
1
|
-
/** @fileoverview HTTP utilities: response helpers, auth, body parsing, and shared types. */
|
|
2
|
-
|
|
3
|
-
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
4
|
-
import { getUserCount } from '../db/auth.ts';
|
|
5
|
-
import { verifyToken } from './auth.ts';
|
|
6
|
-
|
|
7
|
-
export const MIME_TYPES: Record<string, string> = {
|
|
8
|
-
'.html': 'text/html; charset=utf-8',
|
|
9
|
-
'.css': 'text/css; charset=utf-8',
|
|
10
|
-
'.js': 'application/javascript; charset=utf-8',
|
|
11
|
-
'.json': 'application/json; charset=utf-8',
|
|
12
|
-
'.svg': 'image/svg+xml',
|
|
13
|
-
'.png': 'image/png',
|
|
14
|
-
'.ico': 'image/x-icon',
|
|
15
|
-
'.woff2': 'font/woff2',
|
|
16
|
-
};
|
|
17
|
-
|
|
18
|
-
export interface RouteParams {
|
|
19
|
-
[key: string]: string;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
export interface Route {
|
|
23
|
-
method: string;
|
|
24
|
-
pattern: RegExp;
|
|
25
|
-
handler: (req: IncomingMessage, res: ServerResponse, params: RouteParams) => void | Promise<void>;
|
|
26
|
-
skipAuth?: boolean;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export interface WebServerHandle {
|
|
30
|
-
start(): Promise<void>;
|
|
31
|
-
stop(): Promise<void>;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
export interface WebServerDeps {
|
|
35
|
-
slackConnected: () => boolean;
|
|
36
|
-
activeProcessCount: () => number;
|
|
37
|
-
serviceHealth: () => { agent: { available: boolean }; browser: { connected: boolean } };
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
export function pathToRegex(path: string): RegExp {
|
|
41
|
-
const pattern = path.replace(/:(\w+)/g, '(?<$1>[^/]+)');
|
|
42
|
-
return new RegExp(`^${pattern}$`);
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
export function json(res: ServerResponse, data: unknown, status = 200): void {
|
|
46
|
-
const body = JSON.stringify(data);
|
|
47
|
-
res.writeHead(status, {
|
|
48
|
-
'Content-Type': 'application/json; charset=utf-8',
|
|
49
|
-
'Content-Length': Buffer.byteLength(body),
|
|
50
|
-
});
|
|
51
|
-
res.end(body);
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
export function jsonError(res: ServerResponse, message: string, status: number): void {
|
|
55
|
-
json(res, { error: message }, status);
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
export function parsePagination(url: URL): { page: number; perPage: number } {
|
|
59
|
-
const page = Math.max(Number(url.searchParams.get('page')) || 1, 1);
|
|
60
|
-
const perPage = Math.max(Number(url.searchParams.get('perPage')) || 20, 1);
|
|
61
|
-
return { page, perPage };
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
export function parseSystemFlag(url: URL): boolean {
|
|
65
|
-
return url.searchParams.get('system') === 'true';
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
export function parseSortParam(url: URL): string | undefined {
|
|
69
|
-
return url.searchParams.get('sort') ?? undefined;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
export function parseSearchParam(url: URL): string | undefined {
|
|
73
|
-
return url.searchParams.get('search') ?? undefined;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
const MAX_BODY_BYTES = 1024 * 1024;
|
|
77
|
-
|
|
78
|
-
export async function readJsonBody<T>(req: IncomingMessage): Promise<T> {
|
|
79
|
-
return new Promise((resolve, reject) => {
|
|
80
|
-
const chunks: Buffer[] = [];
|
|
81
|
-
let totalBytes = 0;
|
|
82
|
-
req.on('data', (chunk: Buffer) => {
|
|
83
|
-
totalBytes += chunk.length;
|
|
84
|
-
if (totalBytes > MAX_BODY_BYTES) {
|
|
85
|
-
req.destroy();
|
|
86
|
-
reject(new Error('Request body too large'));
|
|
87
|
-
return;
|
|
88
|
-
}
|
|
89
|
-
chunks.push(chunk);
|
|
90
|
-
});
|
|
91
|
-
req.on('end', () => {
|
|
92
|
-
try {
|
|
93
|
-
resolve(JSON.parse(Buffer.concat(chunks).toString()) as T);
|
|
94
|
-
} catch {
|
|
95
|
-
reject(new Error('Invalid JSON body'));
|
|
96
|
-
}
|
|
97
|
-
});
|
|
98
|
-
req.on('error', reject);
|
|
99
|
-
});
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
function extractToken(req: IncomingMessage, allowQueryToken: boolean): string | null {
|
|
103
|
-
const authHeader = req.headers['authorization'] ?? '';
|
|
104
|
-
if (authHeader.startsWith('Bearer ')) return authHeader.slice(7);
|
|
105
|
-
|
|
106
|
-
if (allowQueryToken) {
|
|
107
|
-
const url = new URL(req.url ?? '/', `http://${req.headers.host}`);
|
|
108
|
-
return url.searchParams.get('token');
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
return null;
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
/**
|
|
115
|
-
* Verifies the request is authenticated. In setup mode (no users in DB),
|
|
116
|
-
* all requests are allowed. Otherwise, a valid signed token is required.
|
|
117
|
-
*/
|
|
118
|
-
export function checkAuth(
|
|
119
|
-
req: IncomingMessage,
|
|
120
|
-
res: ServerResponse,
|
|
121
|
-
allowQueryToken = false,
|
|
122
|
-
): boolean {
|
|
123
|
-
if (getUserCount() === 0) return true;
|
|
124
|
-
|
|
125
|
-
const token = extractToken(req, allowQueryToken);
|
|
126
|
-
if (!token || !verifyToken(token)) {
|
|
127
|
-
jsonError(res, 'Unauthorized', 401);
|
|
128
|
-
return false;
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
return true;
|
|
132
|
-
}
|
package/src/server/index.ts
DELETED
|
@@ -1,6 +0,0 @@
|
|
|
1
|
-
/** @fileoverview Server barrel: public API for the server module. */
|
|
2
|
-
|
|
3
|
-
export { broadcastSSE } from './sse.ts';
|
|
4
|
-
export { createWebServer } from './lifecycle.ts';
|
|
5
|
-
export type { WebServerHandle, WebServerDeps } from './http.ts';
|
|
6
|
-
export type { RouteOptions } from './routes.ts';
|