@gleapai/kai-bridge 0.7.0 → 0.9.1

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.
@@ -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
- }
@@ -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); }
@@ -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
- }
@@ -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
- }