@gleapai/kai-bridge 0.7.0 → 0.9.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 +28 -4
- package/bin/kai-bridge.mjs +7 -6
- package/npm-shrinkwrap.json +2 -2
- package/package.json +3 -7
- package/scripts/runtime-smoke.mjs +1 -1
- package/src/daemon.mjs +37 -69
- package/src/executor.mjs +3 -16
- package/src/harness-errors.mjs +8 -0
- package/src/harness-install.mjs +73 -0
- package/src/harnesses.mjs +30 -25
- package/src/{hosted-ports.mjs → preview-ports.mjs} +2 -2
- package/src/preview.mjs +12 -18
- package/src/profiles.mjs +6 -5
- package/src/service.mjs +1 -1
- package/src/setup.mjs +1 -1
- package/src/workspace.mjs +4 -23
- package/fly/Dockerfile +0 -11
- package/fly/UPDATES.md +0 -76
- package/fly/codex-auth.mjs +0 -5
- package/fly/entrypoint.sh +0 -12
- package/fly/login-codex.sh +0 -9
- package/fly/runtime-cli.mjs +0 -15
- package/fly/runtime-cli.sh +0 -10
- package/fly/runtime-launch.mjs +0 -8
- package/fly/runtime-update.mjs +0 -137
- package/fly/start.mjs +0 -7
- package/fly/supervisord.conf +0 -45
- package/src/codex-broker-client.mjs +0 -16
- package/src/codex-broker.mjs +0 -85
- package/src/hosted-auth.mjs +0 -42
- package/src/hosted-git-credential.mjs +0 -24
- package/src/hosted-git.mjs +0 -33
- package/src/hosted-resources.mjs +0 -35
- package/src/hosted-tunnel.mjs +0 -76
- package/src/hosted.mjs +0 -86
package/fly/start.mjs
DELETED
|
@@ -1,7 +0,0 @@
|
|
|
1
|
-
import { bootstrapHosted } from '../src/hosted.mjs';
|
|
2
|
-
import { BridgeDaemon } from '../src/daemon.mjs';
|
|
3
|
-
await bootstrapHosted();
|
|
4
|
-
const daemon = new BridgeDaemon();
|
|
5
|
-
process.on('SIGTERM', () => { void daemon.stop(); });
|
|
6
|
-
process.on('SIGINT', () => { void daemon.stop(); });
|
|
7
|
-
await daemon.start();
|
package/fly/supervisord.conf
DELETED
|
@@ -1,45 +0,0 @@
|
|
|
1
|
-
[unix_http_server]
|
|
2
|
-
file=/run/supervisor.sock
|
|
3
|
-
chmod=0700
|
|
4
|
-
[supervisord]
|
|
5
|
-
nodaemon=true
|
|
6
|
-
logfile=/data/logs/supervisor.log
|
|
7
|
-
pidfile=/run/supervisord.pid
|
|
8
|
-
[rpcinterface:supervisor]
|
|
9
|
-
supervisor.rpcinterface_factory=supervisor.rpcinterface:make_main_rpcinterface
|
|
10
|
-
[supervisorctl]
|
|
11
|
-
serverurl=unix:///run/supervisor.sock
|
|
12
|
-
[program:kai-bridge]
|
|
13
|
-
command=node /opt/kai-bridge/fly/runtime-launch.mjs bridge
|
|
14
|
-
user=kai
|
|
15
|
-
environment=HOME="/data/home/kai",KAI_HOME="/data/home/kai/.kai",DISPLAY=":99"
|
|
16
|
-
autorestart=unexpected
|
|
17
|
-
stopasgroup=true
|
|
18
|
-
killasgroup=true
|
|
19
|
-
stdout_logfile=/data/logs/bridge.log
|
|
20
|
-
stderr_logfile=/data/logs/bridge-error.log
|
|
21
|
-
[program:codex-auth]
|
|
22
|
-
command=node /opt/kai-bridge/fly/runtime-launch.mjs codex-auth
|
|
23
|
-
user=kai
|
|
24
|
-
environment=HOME="/data/home/kai",CODEX_HOME="/data/home/kai/.codex"
|
|
25
|
-
autorestart=true
|
|
26
|
-
stopasgroup=true
|
|
27
|
-
stdout_logfile=/data/logs/codex-auth.log
|
|
28
|
-
stderr_logfile=/data/logs/codex-auth-error.log
|
|
29
|
-
[program:display]
|
|
30
|
-
command=/usr/bin/Xvfb :99 -screen 0 1440x900x24 -nolisten tcp
|
|
31
|
-
user=kai
|
|
32
|
-
autorestart=true
|
|
33
|
-
[program:desktop]
|
|
34
|
-
command=/usr/bin/fluxbox
|
|
35
|
-
user=kai
|
|
36
|
-
environment=DISPLAY=":99",HOME="/data/home/kai"
|
|
37
|
-
autorestart=true
|
|
38
|
-
[program:vnc]
|
|
39
|
-
command=/usr/bin/x11vnc -display :99 -localhost -forever -shared -nopw -rfbport 5900
|
|
40
|
-
user=kai
|
|
41
|
-
autorestart=true
|
|
42
|
-
[program:browser-login]
|
|
43
|
-
command=/usr/bin/websockify --web=/usr/share/novnc 127.0.0.1:6080 127.0.0.1:5900
|
|
44
|
-
user=kai
|
|
45
|
-
autorestart=true
|
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
import { connect } from 'node:net';
|
|
3
|
-
import { spawn } from 'node:child_process';
|
|
4
|
-
import { harnessBinary } from './harnesses.mjs';
|
|
5
|
-
if (process.argv[2] !== 'app-server') {
|
|
6
|
-
// Version/login commands remain native. Refresh for coding sessions is owned
|
|
7
|
-
// exclusively by the supervised app-server.
|
|
8
|
-
const child = spawn(harnessBinary('codex'), process.argv.slice(2), { stdio: 'inherit' });
|
|
9
|
-
child.on('error', () => process.exit(1));
|
|
10
|
-
child.on('exit', code => process.exit(code || 0));
|
|
11
|
-
} else {
|
|
12
|
-
const socket = connect('/data/home/kai/.kai/codex.sock');
|
|
13
|
-
process.stdin.pipe(socket); socket.pipe(process.stdout);
|
|
14
|
-
socket.on('error', () => { process.stderr.write('The Codex authentication service is unavailable. Restart it using maintenance SSH.\n'); process.exit(1); });
|
|
15
|
-
socket.on('close', () => process.exit(0));
|
|
16
|
-
}
|
package/src/codex-broker.mjs
DELETED
|
@@ -1,85 +0,0 @@
|
|
|
1
|
-
// One native Codex app-server (and therefore one refresh-token owner) per VM.
|
|
2
|
-
// ACP adapters retain independent session configuration and talk JSON-RPC
|
|
3
|
-
// through private Unix sockets. No authentication files are copied per turn.
|
|
4
|
-
import { createServer } from 'node:net';
|
|
5
|
-
import { spawn } from 'node:child_process';
|
|
6
|
-
import { createInterface } from 'node:readline';
|
|
7
|
-
import { chmodSync, rmSync, mkdirSync } from 'node:fs';
|
|
8
|
-
import { dirname } from 'node:path';
|
|
9
|
-
import { boundHostedProcess } from './hosted-resources.mjs';
|
|
10
|
-
import { harnessBinary } from './harnesses.mjs';
|
|
11
|
-
|
|
12
|
-
export function startCodexBroker({ socketPath = '/data/home/kai/.kai/codex.sock', command = harnessBinary('codex'), spawnImpl = spawn } = {}) {
|
|
13
|
-
if (!command) throw new Error('The bundled Codex executable is missing.');
|
|
14
|
-
mkdirSync(dirname(socketPath), { recursive: true }); rmSync(socketPath, { force: true });
|
|
15
|
-
const processHandle = spawnImpl(command, ['app-server'], { env: { ...process.env, CODEX_HOME: '/data/home/kai/.codex' }, detached: process.env.KAI_HOSTED === '1', stdio: ['pipe', 'pipe', 'inherit'] });
|
|
16
|
-
boundHostedProcess(processHandle, { limitMb: 1536 });
|
|
17
|
-
const clients = new Set(), pending = new Map(), threadOwners = new Map(), reverse = new Map(), activeTurns = new Map();
|
|
18
|
-
let sequence = 0, initialized, initializeRequest, initializeWaiters = [], notified = false;
|
|
19
|
-
const send = (socket, msg) => { if (!socket.destroyed) socket.write(JSON.stringify(msg) + '\n'); };
|
|
20
|
-
const upstream = msg => processHandle.stdin.write(JSON.stringify(msg) + '\n');
|
|
21
|
-
const server = createServer(socket => {
|
|
22
|
-
clients.add(socket);
|
|
23
|
-
const lines = createInterface({ input: socket });
|
|
24
|
-
lines.on('line', line => {
|
|
25
|
-
let msg; try { msg = JSON.parse(line); } catch { socket.destroy(); return; }
|
|
26
|
-
if (msg.method === 'initialize') {
|
|
27
|
-
if (initialized) { send(socket, { id: msg.id, result: initialized }); return; }
|
|
28
|
-
initializeWaiters.push({ socket, id: msg.id });
|
|
29
|
-
if (!initializeRequest) { initializeRequest = ++sequence; upstream({ ...msg, id: initializeRequest }); }
|
|
30
|
-
return;
|
|
31
|
-
}
|
|
32
|
-
if (msg.method === 'initialized') { if (!notified) { notified = true; upstream(msg); } return; }
|
|
33
|
-
if (!msg.method && reverse.has(String(msg.id))) {
|
|
34
|
-
const req = reverse.get(String(msg.id));
|
|
35
|
-
if (req.socket !== socket) return;
|
|
36
|
-
reverse.delete(String(msg.id)); upstream({ ...msg, id: req.id }); return;
|
|
37
|
-
}
|
|
38
|
-
const thread = msg.params?.threadId;
|
|
39
|
-
if (thread && threadOwners.has(thread) && threadOwners.get(thread) !== socket && !threadOwners.get(thread).destroyed) {
|
|
40
|
-
if (msg.id != null) send(socket, { id: msg.id, error: { code: -32000, message: 'This thread is active in another session.' } });
|
|
41
|
-
return;
|
|
42
|
-
}
|
|
43
|
-
if (thread) threadOwners.set(thread, socket);
|
|
44
|
-
if (msg.id != null) { const id = ++sequence; pending.set(id, { socket, id: msg.id }); upstream({ ...msg, id }); }
|
|
45
|
-
else upstream(msg);
|
|
46
|
-
});
|
|
47
|
-
socket.once('close', () => {
|
|
48
|
-
clients.delete(socket); lines.close();
|
|
49
|
-
initializeWaiters = initializeWaiters.filter(w => w.socket !== socket);
|
|
50
|
-
for (const [threadId, owner] of threadOwners) if (owner === socket && activeTurns.has(threadId))
|
|
51
|
-
upstream({ id: ++sequence, method: 'turn/interrupt', params: { threadId, turnId: activeTurns.get(threadId) } });
|
|
52
|
-
for (const [key, request] of reverse) if (request.socket === socket) { reverse.delete(key); upstream({ id: request.id, error: { code: -32000, message: 'Session owner disconnected.' } }); }
|
|
53
|
-
});
|
|
54
|
-
socket.on('error', () => socket.destroy());
|
|
55
|
-
});
|
|
56
|
-
createInterface({ input: processHandle.stdout }).on('line', line => {
|
|
57
|
-
let msg; try { msg = JSON.parse(line); } catch { return; }
|
|
58
|
-
if (msg.id === initializeRequest && !msg.method) {
|
|
59
|
-
initialized = msg.result;
|
|
60
|
-
for (const waiter of initializeWaiters) send(waiter.socket, { ...msg, id: waiter.id });
|
|
61
|
-
initializeWaiters = []; if (msg.error) initializeRequest = undefined; return;
|
|
62
|
-
}
|
|
63
|
-
if (msg.id != null && !msg.method) {
|
|
64
|
-
const req = pending.get(msg.id); if (!req) return;
|
|
65
|
-
pending.delete(msg.id);
|
|
66
|
-
const thread = msg.result?.thread?.id;
|
|
67
|
-
if (thread) threadOwners.set(thread, req.socket);
|
|
68
|
-
send(req.socket, { ...msg, id: req.id }); return;
|
|
69
|
-
}
|
|
70
|
-
const thread = msg.params?.threadId || msg.params?.thread_id || msg.params?.thread?.id;
|
|
71
|
-
if (thread && msg.method === 'turn/started' && msg.params?.turn?.id) activeTurns.set(thread, msg.params.turn.id);
|
|
72
|
-
if (thread && msg.method === 'turn/completed') activeTurns.delete(thread);
|
|
73
|
-
const socket = thread && threadOwners.get(thread);
|
|
74
|
-
if (socket && !socket.destroyed) {
|
|
75
|
-
if (msg.id != null) { const id = `up-${++sequence}`; reverse.set(id, { socket, id: msg.id }); send(socket, { ...msg, id }); }
|
|
76
|
-
else send(socket, msg);
|
|
77
|
-
} else if (!thread && msg.id == null && /^(account|model|serverStatus)/.test(msg.method || '')) {
|
|
78
|
-
for (const client of clients) send(client, msg);
|
|
79
|
-
} else if (msg.id != null) upstream({ id: msg.id, error: { code: -32000, message: 'Session owner disconnected.' } });
|
|
80
|
-
});
|
|
81
|
-
processHandle.once('exit', () => { for (const client of clients) client.destroy(); server.close(); });
|
|
82
|
-
processHandle.on('error', () => { for (const client of clients) client.destroy(); server.close(); });
|
|
83
|
-
server.listen(socketPath, () => chmodSync(socketPath, 0o600));
|
|
84
|
-
return { close() { for (const client of clients) client.destroy(); server.close(); processHandle.kill('SIGTERM'); rmSync(socketPath, { force: true }); } };
|
|
85
|
-
}
|
package/src/hosted-auth.mjs
DELETED
|
@@ -1,42 +0,0 @@
|
|
|
1
|
-
import { createHash } from 'node:crypto';
|
|
2
|
-
import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
|
-
import { join } from 'node:path';
|
|
4
|
-
|
|
5
|
-
// Only harness authentication failures invalidate a login. An application's
|
|
6
|
-
// HTTP 401 during validation, a quota limit, or an outage must not sign it out.
|
|
7
|
-
export function classifyHarnessFailure(message = '') {
|
|
8
|
-
if (/rate.?limit|429|usage limit|out of (extra )?usage/i.test(message)) return 'rate_limited';
|
|
9
|
-
if (/refresh_token_(reused|expired|invalidated)|not logged in|login (has )?expired|login required|authentication[_ ](?:error|required)|oauth.{0,30}(expired|invalid)|model provider rejected.{0,30}401/i.test(message)) return 'authentication_required';
|
|
10
|
-
if (/service unavailable|provider.{0,30}(unavailable|outage)|HTTP 50[234]|ECONNRESET|ETIMEDOUT/i.test(message)) return 'provider_unavailable';
|
|
11
|
-
return null;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
const marker = (profile, kaiHome) => join(kaiHome, `auth-failure-${createHash('sha256').update(profile.id).digest('hex')}.json`);
|
|
15
|
-
const fingerprint = profile => {
|
|
16
|
-
const hash = createHash('sha256');
|
|
17
|
-
for (const file of profile.harness === 'codex' ? ['auth.json'] : ['.credentials.json']) {
|
|
18
|
-
try { hash.update(readFileSync(join(profile.configDir, file))); } catch { hash.update('missing'); }
|
|
19
|
-
}
|
|
20
|
-
return hash.digest('hex');
|
|
21
|
-
};
|
|
22
|
-
|
|
23
|
-
export function markHostedLoginExpired(profile, kaiHome) {
|
|
24
|
-
mkdirSync(kaiHome, { recursive: true });
|
|
25
|
-
writeFileSync(marker(profile, kaiHome), JSON.stringify({ fingerprint: fingerprint(profile) }), { mode: 0o600 });
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
export function hostedLoginExpired(profile, kaiHome) {
|
|
29
|
-
try {
|
|
30
|
-
const previous = JSON.parse(readFileSync(marker(profile, kaiHome), 'utf8'));
|
|
31
|
-
if (previous.fingerprint === fingerprint(profile)) return true;
|
|
32
|
-
// Native login or a successful supervised refresh replaced the credential.
|
|
33
|
-
rmSync(marker(profile, kaiHome), { force: true });
|
|
34
|
-
} catch { /* No failed login recorded. */ }
|
|
35
|
-
return false;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
export function hostedLoginRecovery(harness) {
|
|
39
|
-
return harness === 'codex'
|
|
40
|
-
? 'Codex sign-in expired. Open maintenance SSH and run sudo /opt/kai-bridge/fly/login-codex.sh, then retry.'
|
|
41
|
-
: 'Claude Code sign-in expired. Open maintenance SSH and run sudo -iu kai claude auth login, then retry.';
|
|
42
|
-
}
|
|
@@ -1,24 +0,0 @@
|
|
|
1
|
-
// Git's credential helper protocol. Credentials go directly to Git's pipe,
|
|
2
|
-
// never into a remote URL, argv, stored config or a daemon log.
|
|
3
|
-
import { readFileSync } from 'node:fs';
|
|
4
|
-
import { join } from 'node:path';
|
|
5
|
-
import { loadConfig, KAI_HOME } from './config.mjs';
|
|
6
|
-
|
|
7
|
-
if (process.argv[2] === 'get') {
|
|
8
|
-
const input = await new Promise(resolve => { let text = ''; process.stdin.on('data', b => { text += b; }); process.stdin.on('end', () => resolve(text)); });
|
|
9
|
-
const fields = Object.fromEntries(String(input).split('\n').filter(Boolean).map(line => { const i = line.indexOf('='); return [line.slice(0, i), line.slice(i + 1)]; }));
|
|
10
|
-
const config = loadConfig();
|
|
11
|
-
const manifest = JSON.parse(readFileSync(join(KAI_HOME, 'hosted-repositories.json'), 'utf8'));
|
|
12
|
-
const remote = `${fields.protocol}://${fields.host}/${fields.path || ''}`;
|
|
13
|
-
const id = manifest[remote] || manifest[`${remote}.git`];
|
|
14
|
-
if (id && config.device?.token && fields.protocol === 'https') {
|
|
15
|
-
const response = await fetch(`${config.apiBase}/gleapcode/bridge/devices/me/hosted-repositories?repositoryId=${encodeURIComponent(id)}`, {
|
|
16
|
-
headers: { authorization: `Bearer ${config.device.token}` }, signal: AbortSignal.timeout(25_000) });
|
|
17
|
-
if (response.ok) {
|
|
18
|
-
const [repo] = await response.json();
|
|
19
|
-
if (repo?.remote === remote || repo?.remote === `${remote}.git`) {
|
|
20
|
-
if ([repo.username, repo.password].every(v => typeof v === 'string' && !/[\r\n]/.test(v))) process.stdout.write(`username=${repo.username}\npassword=${repo.password}\n\n`);
|
|
21
|
-
}
|
|
22
|
-
}
|
|
23
|
-
}
|
|
24
|
-
}
|
package/src/hosted-git.mjs
DELETED
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
// Fly-only Git launcher. Azure uses bearer authentication, which old Git
|
|
3
|
-
// credential helpers cannot express. Add freshly authorized headers to this
|
|
4
|
-
// process's config environment; never persist them in .git/config or argv.
|
|
5
|
-
import { readFileSync, existsSync } from 'node:fs';
|
|
6
|
-
import { spawn } from 'node:child_process';
|
|
7
|
-
import { join } from 'node:path';
|
|
8
|
-
import { loadConfig, KAI_HOME } from './config.mjs';
|
|
9
|
-
const args = process.argv.slice(2), env = { ...process.env, GIT_TERMINAL_PROMPT: '0' };
|
|
10
|
-
const manifestPath = join(KAI_HOME, 'hosted-repositories.json');
|
|
11
|
-
try {
|
|
12
|
-
if (args.some(a => ['clone', 'fetch', 'push', 'pull', 'ls-remote', 'submodule'].includes(a)) && existsSync(manifestPath)) {
|
|
13
|
-
const config = loadConfig(), manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
|
14
|
-
let count = Number(env.GIT_CONFIG_COUNT || 0);
|
|
15
|
-
for (const [remote, id] of Object.entries(manifest)) {
|
|
16
|
-
const response = await fetch(`${config.apiBase}/gleapcode/bridge/devices/me/hosted-repositories?repositoryId=${encodeURIComponent(id)}`, {
|
|
17
|
-
headers: { authorization: `Bearer ${config.device.token}` }, signal: AbortSignal.timeout(25_000) });
|
|
18
|
-
// A revoked companion cannot break an unrelated repository's fetch.
|
|
19
|
-
// The exact remote still fails closed if its own credentials are absent.
|
|
20
|
-
if (!response.ok) continue;
|
|
21
|
-
const [repo] = await response.json();
|
|
22
|
-
if (repo?.remote !== remote || !repo.authHeader || /[\r\n]/.test(repo.authHeader)) continue;
|
|
23
|
-
env[`GIT_CONFIG_KEY_${count}`] = `http.${remote}.extraheader`;
|
|
24
|
-
env[`GIT_CONFIG_VALUE_${count}`] = repo.authHeader; count++;
|
|
25
|
-
}
|
|
26
|
-
env.GIT_CONFIG_COUNT = String(count);
|
|
27
|
-
}
|
|
28
|
-
const sharedOperation = args.some(a => ['clone', 'fetch', 'push', 'pull', 'worktree', 'branch', 'update-ref', 'submodule'].includes(a));
|
|
29
|
-
const lock = sharedOperation && !process.env.KAI_GIT_LOCKED;
|
|
30
|
-
if (lock) env.KAI_GIT_LOCKED = '1';
|
|
31
|
-
const child = spawn(lock ? '/usr/bin/flock' : '/usr/bin/git', lock ? ['--timeout', '180', '/data/git-operations.lock', '/usr/bin/git', ...args] : args, { env, stdio: 'inherit' });
|
|
32
|
-
child.on('error', () => process.exit(1)); child.on('exit', code => process.exit(code || 0));
|
|
33
|
-
} catch (error) { process.stderr.write(`${error.message}\n`); process.exit(1); }
|
package/src/hosted-resources.mjs
DELETED
|
@@ -1,35 +0,0 @@
|
|
|
1
|
-
import { execFile } from 'node:child_process';
|
|
2
|
-
import { promisify } from 'node:util';
|
|
3
|
-
const exec = promisify(execFile);
|
|
4
|
-
|
|
5
|
-
export function processTreeMemory(rows, root) {
|
|
6
|
-
const parsed = rows.trim().split('\n').map(line => line.trim().split(/\s+/).map(Number)).filter(p => p.length === 3 && p.every(Number.isFinite));
|
|
7
|
-
const owned = new Set([Number(root)]);
|
|
8
|
-
for (let changed = true; changed;) { changed = false; for (const [pid, parent] of parsed) if (owned.has(parent) && !owned.has(pid)) { owned.add(pid); changed = true; } }
|
|
9
|
-
return parsed.filter(([pid]) => owned.has(pid)).reduce((sum, [, , rss]) => sum + rss, 0) / 1024;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
/** Bound a workload including subprocesses, reserving room for Bridge,
|
|
13
|
-
* Codex's shared auth process, a second session and preview services. Root SSH
|
|
14
|
-
* can change the VM; the provider-side time/access limits remain authoritative. */
|
|
15
|
-
export function boundHostedProcess(child, { limitMb = 2048, onExceeded = () => {} } = {}) {
|
|
16
|
-
if (process.env.KAI_HOSTED !== '1' || !child.pid) return () => {};
|
|
17
|
-
let stopped = false, reading = false, killTimer;
|
|
18
|
-
const timer = setInterval(async () => {
|
|
19
|
-
if (stopped || reading) return;
|
|
20
|
-
reading = true;
|
|
21
|
-
try {
|
|
22
|
-
const { stdout } = await exec('ps', ['-eo', 'pid=,ppid=,rss='], { timeout: 2000, maxBuffer: 2 * 1024 * 1024 });
|
|
23
|
-
if (!stopped && processTreeMemory(stdout, child.pid) > limitMb) {
|
|
24
|
-
onExceeded(`This workload exceeded its ${limitMb} MB memory budget. Reduce build concurrency or stop another preview, then retry.`);
|
|
25
|
-
try { process.kill(-child.pid, 'SIGTERM'); } catch { child.kill('SIGTERM'); }
|
|
26
|
-
killTimer = setTimeout(() => { try { process.kill(-child.pid, 'SIGKILL'); } catch {} }, 5000);
|
|
27
|
-
stopped = true; clearInterval(timer);
|
|
28
|
-
}
|
|
29
|
-
} catch { /* Failed observation never claims that validation passed. */ }
|
|
30
|
-
finally { reading = false; }
|
|
31
|
-
}, 3000);
|
|
32
|
-
timer.unref?.();
|
|
33
|
-
const cleanup = () => { stopped = true; clearInterval(timer); };
|
|
34
|
-
child.once('close', cleanup); return cleanup;
|
|
35
|
-
}
|
package/src/hosted-tunnel.mjs
DELETED
|
@@ -1,76 +0,0 @@
|
|
|
1
|
-
import WebSocket from 'ws';
|
|
2
|
-
import http from 'node:http';
|
|
3
|
-
import https from 'node:https';
|
|
4
|
-
|
|
5
|
-
export class HostedTunnel {
|
|
6
|
-
constructor({ config, api, log = () => {} }) { this.config = config; this.api = api; this.log = log; this.services = new Map(); this.pending = new Map(); this.closed = false; }
|
|
7
|
-
connect() {
|
|
8
|
-
if (!process.env.KAI_TUNNEL_URL || this.closed) return;
|
|
9
|
-
this.socket = new WebSocket(process.env.KAI_TUNNEL_URL, { headers: { authorization: `Bearer ${this.config.device.token}` }, maxPayload: 12 * 1024 * 1024 });
|
|
10
|
-
this.socket.on('message', raw => { try { this.handle(JSON.parse(String(raw))); } catch { this.socket.close(1008); } });
|
|
11
|
-
this.socket.on('error', () => {});
|
|
12
|
-
this.socket.on('close', () => {
|
|
13
|
-
for (const request of this.pending.values()) { request.destroy?.(); request.close?.(); }
|
|
14
|
-
this.pending.clear(); if (!this.closed) this.timer = setTimeout(() => this.connect(), 3000);
|
|
15
|
-
});
|
|
16
|
-
}
|
|
17
|
-
send(message) {
|
|
18
|
-
if (this.socket?.bufferedAmount > 16 * 1024 * 1024) { this.socket.close(1013, 'Preview transport backpressure'); return; }
|
|
19
|
-
if (this.socket?.readyState === WebSocket.OPEN) this.socket.send(JSON.stringify(message));
|
|
20
|
-
}
|
|
21
|
-
async register(sessionId, name, port, protocol = 'http') {
|
|
22
|
-
const service = await this.api.request('POST', '/gleapcode/bridge/devices/me/preview-services', { sessionId, name, port, protocol });
|
|
23
|
-
this.services.set(service.key, { sessionId, name, port, protocol });
|
|
24
|
-
return service.url;
|
|
25
|
-
}
|
|
26
|
-
unregisterSession(sessionId) {
|
|
27
|
-
for (const [key, service] of this.services) if (service.sessionId === sessionId) {
|
|
28
|
-
this.services.delete(key);
|
|
29
|
-
for (const [id, pending] of this.pending) if (pending.serviceKey === key) { pending.destroy?.(); pending.close?.(); this.pending.delete(id); this.send({ type: 'end', id }); }
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
handle(message) {
|
|
33
|
-
const { id } = message;
|
|
34
|
-
if (message.type === 'cancel') { const pending = this.pending.get(id); pending?.destroy?.(); pending?.close?.(); this.pending.delete(id); return; }
|
|
35
|
-
if (message.type === 'ws-data') {
|
|
36
|
-
const socket = this.pending.get(id), data = Buffer.from(message.data || '', 'base64');
|
|
37
|
-
if (!socket) return;
|
|
38
|
-
if (socket.readyState === WebSocket.OPEN && socket.bufferedAmount < 4 * 1024 * 1024) socket.send(data, { binary: !!message.binary });
|
|
39
|
-
else if (socket.readyState === WebSocket.CONNECTING && (socket.earlyBytes || 0) + data.length < 1024 * 1024) { socket.earlyBytes = (socket.earlyBytes || 0) + data.length; socket.early.push({ data, binary: !!message.binary }); }
|
|
40
|
-
else { socket.close(1013); this.send({ type: 'error', id }); }
|
|
41
|
-
return;
|
|
42
|
-
}
|
|
43
|
-
const service = this.services.get(message.key);
|
|
44
|
-
// The relay may reach only services registered by THIS daemon. It cannot
|
|
45
|
-
// turn into an arbitrary loopback / private-network proxy.
|
|
46
|
-
if (!service || service.port !== message.port || service.protocol !== message.protocol || !String(message.path).startsWith('/')) { this.send({ type: 'error', id }); return; }
|
|
47
|
-
if (this.pending.size >= 64) { this.send({ type: 'error', id }); return; }
|
|
48
|
-
const headers = { ...message.headers };
|
|
49
|
-
if (message.type === 'http') {
|
|
50
|
-
const client = service.protocol === 'https' ? https : http;
|
|
51
|
-
const request = client.request({ hostname: '127.0.0.1', port: service.port, method: message.method,
|
|
52
|
-
path: message.path, headers, rejectUnauthorized: false, timeout: 60_000 }, response => {
|
|
53
|
-
this.send({ type: 'headers', id, status: response.statusCode, headers: response.headers });
|
|
54
|
-
response.on('data', data => this.send({ type: 'data', id, data: data.toString('base64') }));
|
|
55
|
-
response.on('end', () => { this.send({ type: 'end', id }); this.pending.delete(id); });
|
|
56
|
-
});
|
|
57
|
-
this.pending.set(id, request);
|
|
58
|
-
request.serviceKey = message.key;
|
|
59
|
-
request.on('timeout', () => request.destroy());
|
|
60
|
-
request.on('error', () => { this.send({ type: 'error', id }); this.pending.delete(id); });
|
|
61
|
-
request.end(Buffer.from(message.body || '', 'base64'));
|
|
62
|
-
} else if (message.type === 'ws') {
|
|
63
|
-
// The local client generates its own handshake headers.
|
|
64
|
-
const protocols = headers['sec-websocket-protocol']?.split(',').map(p => p.trim()).filter(Boolean);
|
|
65
|
-
for (const key of Object.keys(headers)) if (key.toLowerCase().startsWith('sec-websocket-')) delete headers[key];
|
|
66
|
-
const socket = new WebSocket(`${service.protocol === 'https' ? 'wss' : 'ws'}://127.0.0.1:${service.port}${message.path}`, protocols || [], { headers, rejectUnauthorized: false, maxPayload: 12 * 1024 * 1024 });
|
|
67
|
-
this.pending.set(id, socket);
|
|
68
|
-
socket.serviceKey = message.key; socket.early = []; socket.earlyBytes = 0;
|
|
69
|
-
socket.on('open', () => { for (const frame of socket.early) socket.send(frame.data, { binary: frame.binary }); socket.early = []; socket.earlyBytes = 0; });
|
|
70
|
-
socket.on('message', (data, binary) => this.send({ type: 'data', id, data: Buffer.from(data).toString('base64'), binary }));
|
|
71
|
-
socket.on('close', () => { this.pending.delete(id); this.send({ type: 'end', id }); });
|
|
72
|
-
socket.on('error', () => { this.pending.delete(id); this.send({ type: 'error', id }); });
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
close() { this.closed = true; clearTimeout(this.timer); this.socket?.close(); for (const p of this.pending.values()) { p.destroy?.(); p.close?.(); } }
|
|
76
|
-
}
|
package/src/hosted.mjs
DELETED
|
@@ -1,86 +0,0 @@
|
|
|
1
|
-
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
2
|
-
import { freemem } from 'node:os';
|
|
3
|
-
import { join, dirname } from 'node:path';
|
|
4
|
-
import { fileURLToPath } from 'node:url';
|
|
5
|
-
import { spawn } from 'node:child_process';
|
|
6
|
-
import { createHash } from 'node:crypto';
|
|
7
|
-
import { defaultConfig, loadConfig, saveConfig, KAI_HOME, DEFAULT_API_BASE } from './config.mjs';
|
|
8
|
-
|
|
9
|
-
export async function bootstrapHosted() {
|
|
10
|
-
if (process.env.KAI_HOSTED !== '1') return;
|
|
11
|
-
const config = loadConfig();
|
|
12
|
-
if (config.device?.token) return;
|
|
13
|
-
const response = await fetch(`${DEFAULT_API_BASE}/gleapcode/hosted-bootstrap`, { method: 'POST',
|
|
14
|
-
headers: { 'content-type': 'application/json' }, signal: AbortSignal.timeout(30_000),
|
|
15
|
-
body: JSON.stringify({ token: process.env.KAI_BOOTSTRAP_TOKEN, machineId: process.env.KAI_MACHINE_ID,
|
|
16
|
-
generation: Number(process.env.KAI_MACHINE_GENERATION) }) });
|
|
17
|
-
if (!response.ok) throw new Error(`Machine bootstrap failed (${response.status}). Use maintenance SSH to repair.`);
|
|
18
|
-
const data = await response.json();
|
|
19
|
-
saveConfig({ ...defaultConfig(), ...data, autoUpdate: false });
|
|
20
|
-
delete process.env.KAI_BOOTSTRAP_TOKEN;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
export function hostedResources(daemon) {
|
|
24
|
-
return { availableMemoryMb: Math.floor(freemem() / 1024 / 1024),
|
|
25
|
-
activeWork: daemon.running.size + (daemon.hostedCloning ? 1 : 0) + daemon.logins.size + (daemon.hostedPreviewWork?.size || 0),
|
|
26
|
-
queuedWork: daemon.hostedQueued?.size || 0, repositories: daemon.hostedRepositorySetup };
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
function git(args, cwd) {
|
|
30
|
-
return new Promise((resolve, reject) => {
|
|
31
|
-
const child = spawn('git', args, { cwd, env: { ...process.env, GIT_TERMINAL_PROMPT: '0' }, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
32
|
-
let error = '';
|
|
33
|
-
child.stderr.on('data', b => { error = (error + b).slice(-2000); });
|
|
34
|
-
const timer = setTimeout(() => child.kill('SIGTERM'), 180_000);
|
|
35
|
-
child.once('error', e => { clearTimeout(timer); reject(e); });
|
|
36
|
-
child.once('exit', code => { clearTimeout(timer); code === 0 ? resolve() : reject(new Error(`Repository preparation failed (${code}): ${error}`)); });
|
|
37
|
-
});
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
export async function syncHostedRepositories(daemon) {
|
|
41
|
-
if (!daemon.config.hosted || daemon.hostedCloning || daemon.stopped) return;
|
|
42
|
-
daemon.hostedCloning = true;
|
|
43
|
-
const readyIds = [];
|
|
44
|
-
daemon.hostedRepositorySetup = { status: 'cloning', readyIds };
|
|
45
|
-
try {
|
|
46
|
-
const repos = await daemon.api.request('GET', '/gleapcode/bridge/devices/me/hosted-repositories');
|
|
47
|
-
const root = '/data/repos'; mkdirSync(root, { recursive: true });
|
|
48
|
-
const helperPath = join(dirname(fileURLToPath(import.meta.url)), 'hosted-git-credential.mjs');
|
|
49
|
-
const manifest = Object.fromEntries(repos.map(repo => [repo.remote, repo.id]));
|
|
50
|
-
writeFileSync(join(daemon.kaiHome, 'hosted-repositories.json'), JSON.stringify(manifest), { mode: 0o600 });
|
|
51
|
-
for (const repo of repos) {
|
|
52
|
-
const name = createHash('sha256').update(repo.key || repo.remote).digest('hex');
|
|
53
|
-
const target = join(root, name), staging = `${target}.cloning`;
|
|
54
|
-
if (existsSync(join(target, '.git'))) { readyIds.push(repo.id); continue; }
|
|
55
|
-
// Never erase a failed clone's directory or a user-created checkout.
|
|
56
|
-
if (existsSync(staging) || existsSync(target)) throw new Error(`Repository ${repo.fullName} needs attention at ${staging}. Existing files were preserved.`);
|
|
57
|
-
const helper = `!${JSON.stringify(process.execPath)} ${JSON.stringify(helperPath)}`;
|
|
58
|
-
await git(['-c', 'credential.helper=', '-c', `credential.helper=${helper}`, '-c', 'credential.useHttpPath=true', 'clone', '--', repo.remote, staging], root);
|
|
59
|
-
await git(['config', '--local', 'credential.helper', helper], staging);
|
|
60
|
-
await git(['config', '--local', 'credential.useHttpPath', 'true'], staging);
|
|
61
|
-
renameSync(staging, target);
|
|
62
|
-
readyIds.push(repo.id);
|
|
63
|
-
}
|
|
64
|
-
await daemon.scanRepos(); await daemon.hello();
|
|
65
|
-
daemon.hostedRepositorySetup = { status: 'ready', readyIds };
|
|
66
|
-
} catch (error) {
|
|
67
|
-
daemon.hostedRepositorySetup = { status: 'error', error: error.message, readyIds };
|
|
68
|
-
throw error;
|
|
69
|
-
} finally { daemon.hostedCloning = false; }
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
/** FIFO contents persist on the server; duplicate push/replay cannot run twice. */
|
|
73
|
-
export async function awaitHostedAdmission(daemon, turn) {
|
|
74
|
-
if (!daemon.config.hosted) return true;
|
|
75
|
-
daemon.hostedQueued ??= new Set();
|
|
76
|
-
if (daemon.hostedQueued.has(turn.turnId) || daemon.running.has(turn.turnId)) return false;
|
|
77
|
-
daemon.hostedQueued.add(turn.turnId);
|
|
78
|
-
try {
|
|
79
|
-
while (!daemon.stopped && !daemon.hostedCancelled?.has(turn.turnId)) {
|
|
80
|
-
const admission = await daemon.api.request('POST', `/gleapcode/bridge/turns/${turn.turnId}/admit`, {});
|
|
81
|
-
if (admission.admitted) return true;
|
|
82
|
-
await new Promise(r => setTimeout(r, 2000));
|
|
83
|
-
}
|
|
84
|
-
return false;
|
|
85
|
-
} finally { daemon.hostedQueued.delete(turn.turnId); }
|
|
86
|
-
}
|