@gleapai/kai-bridge 0.5.0 → 0.7.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/src/harnesses.mjs CHANGED
@@ -19,9 +19,11 @@ import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync
19
19
  import { arch, homedir, platform } from "node:os";
20
20
  import { dirname, join, resolve as resolvePath } from "node:path";
21
21
  import { fileURLToPath } from "node:url";
22
+ import { createRequire } from 'node:module';
22
23
 
23
24
  const PKG = join(dirname(fileURLToPath(import.meta.url)), "..");
24
25
  const PKG_BIN = join(PKG, "node_modules", ".bin");
26
+ const require = createRequire(import.meta.url);
25
27
 
26
28
  export const HARNESS_IDS = ["claude", "codex", "cursor"];
27
29
 
@@ -76,21 +78,30 @@ export function cursorDownloadUrl(version = CURSOR_AGENT_VERSION) {
76
78
  /** Bundled / installed binary for a harness, or null. */
77
79
  export function harnessBinary(harness, kaiHome = join(homedir(), ".kai")) {
78
80
  if (harness === "claude") {
79
- const pkgDir = join(PKG, "node_modules", "@anthropic-ai");
80
81
  try {
81
- const plat = readdirSync(pkgDir).find((d) => d.startsWith("claude-agent-sdk-") && d !== "claude-agent-sdk");
82
- if (plat) {
83
- const bin = join(pkgDir, plat, platform() === "win32" ? "claude.exe" : "claude");
84
- if (existsSync(bin)) return bin;
85
- }
82
+ // Resolve from the SDK exactly as the ACP adapter does, including nested
83
+ // npm installs and the correct libc. Directory order is not a platform check.
84
+ const sdkRequire = createRequire(require.resolve('@anthropic-ai/claude-agent-sdk'));
85
+ const libc = platform() === 'linux' && !process.report.getReport().header.glibcVersionRuntime ? '-musl' : '';
86
+ return sdkRequire.resolve(`@anthropic-ai/claude-agent-sdk-${platform()}-${arch()}${libc}/claude${platform() === 'win32' ? '.exe' : ''}`);
86
87
  } catch {
87
88
  /* not installed */
88
89
  }
89
90
  return null;
90
91
  }
91
92
  if (harness === "codex") {
92
- const bin = join(PKG_BIN, platform() === "win32" ? "codex.cmd" : "codex");
93
- return existsSync(bin) ? bin : null;
93
+ try {
94
+ if (platform() === 'win32') {
95
+ // execFile/ACP cannot execute a JS entrypoint or .cmd shim directly on
96
+ // Windows. Resolve the same pinned package's native executable there.
97
+ const codexRequire = createRequire(require.resolve('@openai/codex/package.json'));
98
+ const root = dirname(codexRequire.resolve(`@openai/codex-win32-${arch()}/package.json`));
99
+ const triple = `${arch() === 'arm64' ? 'aarch64' : 'x86_64'}-pc-windows-msvc`;
100
+ const binary = join(root, 'vendor', triple, 'bin', 'codex.exe');
101
+ return existsSync(binary) ? binary : null;
102
+ }
103
+ return require.resolve('@openai/codex/bin/codex.js');
104
+ } catch { return null; }
94
105
  }
95
106
  if (harness === "cursor") {
96
107
  const bin = join(CURSOR_CURRENT(kaiHome), "dist-package", platform() === "win32" ? "cursor-agent.exe" : "cursor-agent");
@@ -194,7 +205,7 @@ export function harnessLoginCommand(harness, configDir, kaiHome) {
194
205
  // Ambient login must land in the keychain (env untouched) so the
195
206
  // equally-untouched probe and turns find it; managed dirs are exported.
196
207
  const env = resolvePath(configDir) !== resolvePath(claudeDefault) ? { CLAUDE_CONFIG_DIR: configDir } : {};
197
- return { cmd: bin, args: ["login"], env };
208
+ return { cmd: bin, args: ["auth", "login"], env };
198
209
  }
199
210
  if (harness === "codex") return { cmd: bin, args: ["login"], env: { CODEX_HOME: configDir } };
200
211
  return { cmd: bin, args: ["login"], env: {} };
@@ -0,0 +1,42 @@
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
+ }
@@ -0,0 +1,24 @@
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
+ }
@@ -0,0 +1,33 @@
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); }
@@ -0,0 +1,20 @@
1
+ // Reserve before boot: two sessions can otherwise choose the same free port.
2
+ export class HostedPortPool {
3
+ constructor() { this.owners = new Map(); this.tail = Promise.resolve(); }
4
+ reserve(owner, preferred, pinned, listening) {
5
+ const operation = this.tail.then(async () => {
6
+ const current = [...this.owners].find(([, who]) => who === owner);
7
+ if (current) return current[0];
8
+ const first = Number.isInteger(preferred) && preferred >= 1024 && preferred <= 65535 ? preferred : 43000;
9
+ for (let i = 0; i < (pinned ? 1 : 10000); i++) {
10
+ const port = 1024 + ((first - 1024 + i) % (65536 - 1024));
11
+ if (this.owners.has(port) || await listening(port)) continue;
12
+ this.owners.set(port, owner); return port;
13
+ }
14
+ throw new Error(pinned ? `Port ${first} is already reserved. Use a port variable in .gleap/dev.yaml to run concurrent previews.` : 'No preview ports are available. Stop an unused preview and retry.');
15
+ });
16
+ this.tail = operation.catch(() => {}); return operation;
17
+ }
18
+ releaseSession(sessionId) { for (const [port, owner] of this.owners) if (owner.startsWith(`${sessionId}/`)) this.owners.delete(port); }
19
+ }
20
+ export const hostedPorts = new HostedPortPool();
@@ -0,0 +1,35 @@
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
+ }
@@ -0,0 +1,76 @@
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 ADDED
@@ -0,0 +1,86 @@
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
+ }
package/src/preview.mjs CHANGED
@@ -1,3 +1,5 @@
1
+ import { hostedPorts } from './hosted-ports.mjs';
2
+ import { boundHostedProcess } from './hosted-resources.mjs';
1
3
  // Preview tier A — run the app's real dev servers next to the session.
2
4
  //
3
5
  // Each repo may commit a `.gleap/dev.yaml`:
@@ -304,8 +306,11 @@ export function lanAddress() {
304
306
  }
305
307
 
306
308
  /** `${port:api}` → assigned port of service `api`. */
307
- export function substitutePorts(value, ports) {
308
- return String(value).replace(/\$\{port:([\w-]+)\}/g, (_, name) => String(ports[name] ?? ""));
309
+ export function substitutePorts(value, ports, urls = {}) {
310
+ return String(value).replace(/\$\{port:([\w-]+)\}/g, (_, name) => String(ports[name] ?? "")).replace(/\$\{url:([\w-]+)\}/g, (_, name) => {
311
+ if (!urls[name]) throw new Error(`Service URL is not available for ${name}.`);
312
+ return urls[name];
313
+ });
309
314
  }
310
315
 
311
316
  /** GET `url` accepting self-signed certificates (dev https); resolves `{ status, contentType }` or null. */
@@ -384,6 +389,7 @@ function lockfileHash(cwd) {
384
389
 
385
390
  /** Runs `cmd` through the user's login shell (darwin) or the platform shell, capturing output into `fd`. */
386
391
  function spawnShell(cmd, { cwd, env, fd, detached = false }) {
392
+ if (process.env.KAI_HOSTED === '1') { env = { ...env, NODE_OPTIONS: '--max-old-space-size=1024' }; detached = true; }
387
393
  if (process.platform !== "win32") cmd = withDaemonNode(cmd);
388
394
  if (process.platform === "darwin") {
389
395
  // Under launchd the daemon's PATH is frozen at install time
@@ -393,7 +399,9 @@ function spawnShell(cmd, { cwd, env, fd, detached = false }) {
393
399
  }
394
400
  // Windows has no process groups to kill and `detached` would open a
395
401
  // console window — taskkill /T does the tree.
396
- return spawn(cmd, { cwd, env, shell: true, stdio: ["ignore", fd, fd], detached: process.platform !== "win32" && detached, windowsHide: true });
402
+ const child = spawn(cmd, { cwd, env, shell: true, stdio: ["ignore", fd, fd], detached: process.platform !== "win32" && detached, windowsHide: true });
403
+ boundHostedProcess(child, { limitMb: 1536 });
404
+ return child;
397
405
  }
398
406
 
399
407
  /**
@@ -411,10 +419,12 @@ export class ServiceRunner {
411
419
  * fires when a service that had become ready dies on its own;
412
420
  * `onProcess(name, pid, "add" | "remove")` lets the daemon persist pids.
413
421
  */
414
- constructor({ kaiHome, sessionId, log = () => {}, onStatus = () => {}, preferredPort = null, describeListener = null, settleMs = DEFAULT_SETTLE_MS, onServiceExit = () => {}, onProcess = () => {}, home = homedir() } = {}) {
422
+ constructor({ kaiHome, sessionId, log = () => {}, onStatus = () => {}, preferredPort = null, describeListener = null, settleMs = DEFAULT_SETTLE_MS, onServiceExit = () => {}, onProcess = () => {}, home = homedir(), registerPublicService = null } = {}) {
415
423
  if (settleMs === DEFAULT_SETTLE_MS) settleMs = defaultSettleMs();
416
424
  this.kaiHome = kaiHome;
417
425
  this.sessionId = sessionId;
426
+ this.registerPublicService = registerPublicService;
427
+ this.publicUrls = {};
418
428
  this.log = log;
419
429
  this.onStatus = onStatus;
420
430
  this.preferredPort = typeof preferredPort === "function" ? preferredPort : null;
@@ -469,6 +479,11 @@ export class ServiceRunner {
469
479
  }
470
480
  if (this.ports[svc.name]) continue;
471
481
  const declared = svc.port;
482
+ if (process.env.KAI_HOSTED === '1') {
483
+ const pinned = !!declared && (mode === 'local' || new RegExp(`(^|[^0-9])${declared}([^0-9]|$)`).test(`${svc.run} ${Object.values(svc.env).join(' ')}`));
484
+ this.ports[svc.name] = await hostedPorts.reserve(`${this.sessionId}/${repoRoot}/${svc.name}`, declared || await this.preferredPort?.({ repoKey, service: svc.name }), pinned, isPortListening);
485
+ continue;
486
+ }
472
487
  if (declared && !(await isPortListening(declared))) {
473
488
  this.ports[svc.name] = declared;
474
489
  continue;
@@ -500,6 +515,9 @@ export class ServiceRunner {
500
515
  }
501
516
  this.ports[svc.name] = (declared ? await findFreePortNear(declared) : await this.stablePort(repoKey, svc.name)) ?? (await getFreePort());
502
517
  }
518
+ for (const svc of services) {
519
+ this.publicUrls[svc.name] = this.registerPublicService ? await this.registerPublicService(svc.name, this.ports[svc.name], svc.protocol) : `${svc.protocol || 'http'}://localhost:${this.ports[svc.name]}`;
520
+ }
503
521
  this.registered.set(repoRoot, services);
504
522
  this.previewNames ??= new Map();
505
523
  this.previewNames.set(repoRoot, previewName);
@@ -576,7 +594,7 @@ export class ServiceRunner {
576
594
  const env = {
577
595
  ...process.env,
578
596
  PORT: String(port),
579
- ...Object.fromEntries(Object.entries(svc.env).map(([k, v]) => [k, substitutePorts(v, this.ports)])),
597
+ ...Object.fromEntries(Object.entries(svc.env).map(([k, v]) => [k, substitutePorts(v, this.ports, this.publicUrls)])),
580
598
  KAI_SESSION_ID: String(this.sessionId),
581
599
  BROWSER: "none",
582
600
  };
@@ -585,10 +603,12 @@ export class ServiceRunner {
585
603
  // straight into `command not found` was the #1 preview failure. The
586
604
  // install is authoritative: when it fails, the dev command never runs.
587
605
  await this.ensureDeps(cwd, { env, fd, logPath, service: name, repoKey });
588
- const cmd = stripOpenFlag(substitutePorts(svc.run, this.ports));
606
+ const cmd = stripOpenFlag(substitutePorts(svc.run, this.ports, this.publicUrls));
589
607
  const child = spawnShell(cmd, { cwd, env, fd, detached: true });
590
608
  m.ready = false;
591
609
  child.on("exit", (code) => {
610
+ // An old shell can exit after a replacement has been admitted.
611
+ if (this.processes.get(name) !== child) return;
592
612
  this.processes.delete(name);
593
613
  this.onProcess(name, child.pid, "remove");
594
614
  this.log("info", "service.exit", { name, code });
@@ -804,7 +824,11 @@ export class ServiceRunner {
804
824
  const child = this.processes.get(name);
805
825
  if (!child) return;
806
826
  this.stopping.add(name);
807
- const gone = new Promise((r) => child.once("exit", r));
827
+ const port = this.ports[name];
828
+ let timer;
829
+ const gone = child.exitCode !== null || child.signalCode !== null
830
+ ? Promise.resolve()
831
+ : new Promise((r) => child.once("exit", r));
808
832
  if (process.platform === "win32") {
809
833
  spawn("taskkill", ["/PID", String(child.pid), "/T", "/F"], { stdio: "ignore", windowsHide: true }).on("error", () => {});
810
834
  } else {
@@ -819,17 +843,29 @@ export class ServiceRunner {
819
843
  }
820
844
  }
821
845
  this.log("info", "service.stop", { name });
822
- await Promise.race([gone, new Promise((r) => setTimeout(r, timeoutMs))]);
823
- if (this.processes.has(name)) {
846
+ await Promise.race([gone, new Promise((r) => { timer = setTimeout(r, timeoutMs); })]);
847
+ clearTimeout(timer);
848
+ // On Linux the shell may exit before its children close the listener.
849
+ // Starting the replacement now can mistake that old listener for readiness
850
+ // and then crash with EADDRINUSE. Wait for the owned service's port too.
851
+ const deadline = Date.now() + timeoutMs;
852
+ while (port && await isPortListening(port) && Date.now() < deadline) {
853
+ await new Promise(r => setTimeout(r, 50));
854
+ }
855
+ if (this.processes.get(name) === child || (port && await isPortListening(port))) {
824
856
  try {
825
857
  process.platform === "win32" ? child.kill() : process.kill(-child.pid, "SIGKILL");
826
858
  } catch {
827
859
  /* gone */
828
860
  }
829
861
  }
862
+ const forceDeadline = Date.now() + 1000;
863
+ while (port && await isPortListening(port) && Date.now() < forceDeadline) await new Promise(r => setTimeout(r, 50));
864
+ if (port && await isPortListening(port)) throw new PreviewError(`${name} did not release port ${port}; retry after checking the service.`, { code: 'port_busy', service: name });
830
865
  }
831
866
 
832
867
  stopAll() {
868
+ if (process.env.KAI_HOSTED === '1') hostedPorts.releaseSession(this.sessionId);
833
869
  for (const [name, child] of this.processes) {
834
870
  this.stopping.add(name);
835
871
  if (process.platform === "win32") {
package/src/profiles.mjs CHANGED
@@ -138,12 +138,15 @@ export function openLoginTerminal(harness, configDir) {
138
138
  : harness === "codex"
139
139
  ? `CODEX_HOME=${shellQuote(configDir)} `
140
140
  : "NO_OPEN_BROWSER=0 ";
141
- const line = `${envPrefix}${shellQuote(c.cmd)} login`;
141
+ const line = `${harness === 'claude' && process.platform !== 'win32' ? 'DISABLE_AUTOUPDATER=1 ' : ''}${envPrefix}${[c.cmd, ...c.args].map(shellQuote).join(' ')}`;
142
142
  if (process.platform === "darwin") {
143
143
  return spawn("osascript", ["-e", `tell application "Terminal" to activate`, "-e", `tell application "Terminal" to do script ${JSON.stringify(line)}`], { stdio: "ignore", detached: true });
144
144
  }
145
145
  if (process.platform === "win32") {
146
- return spawn("cmd", ["/c", "start", "cmd", "/k", line.replace(/^([A-Z_]+)=('[^']*') /, "set $1=$2 && ")], { stdio: "ignore", detached: true, shell: true });
146
+ // cmd uses double quotes, and profile variables belong in the child env;
147
+ // POSIX KEY='value' prefixes are not valid Windows commands.
148
+ const windowsLine = [c.cmd, ...c.args].map(value => `"${String(value).replace(/"/g, '""')}"`).join(' ');
149
+ return spawn("cmd", ["/c", "start", '""', "cmd", "/k", windowsLine], { env: { ...c.env, ...(harness === 'claude' ? { DISABLE_AUTOUPDATER: '1' } : {}) }, stdio: "ignore", detached: true, shell: true });
147
150
  }
148
151
  for (const term of ["x-terminal-emulator", "gnome-terminal", "konsole", "xterm"]) {
149
152
  if (findBinary(term)) {
@@ -0,0 +1,96 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { existsSync, mkdirSync, rmSync } from 'node:fs';
3
+ import { join, resolve, dirname, isAbsolute } from 'node:path';
4
+ import { homedir } from 'node:os';
5
+ import { inspectCheckout, normalizeRemote } from './repos.mjs';
6
+
7
+ const locks = new Map();
8
+ export function withRepositoryLock(target, action) {
9
+ const key = resolve(target);
10
+ const prior = locks.get(key) || Promise.resolve();
11
+ const work = prior.catch(() => {}).then(action);
12
+ const tail = work.catch(() => {});
13
+ locks.set(key, tail);
14
+ void tail.then(() => { if (locks.get(key) === tail) locks.delete(key); });
15
+ return work;
16
+ }
17
+
18
+ export function locateRepository(rawPath, repoKey) {
19
+ const expanded = String(rawPath || '').trim().replace(/^~(?=$|\/)/, homedir());
20
+ if (!isAbsolute(expanded)) throw new Error('Enter an absolute folder path on this device.');
21
+ const target = resolve(expanded);
22
+ if (!existsSync(target)) throw new Error(`${target} does not exist on this machine.`);
23
+ // Validate BEFORE recording an override or adding a scan root.
24
+ if (repoKey && inspectCheckout(target)?.key !== repoKey) {
25
+ throw new Error(`${target} is not a checkout of ${repoKey}. Choose the folder for that repository.`);
26
+ }
27
+ return target;
28
+ }
29
+
30
+ export function cloneRepository({ remote, name, root, repoKey, timeoutMs = 180_000 }, spawnGit = spawn) {
31
+ if (typeof remote !== 'string' || !(/^(https?|ssh):\/\/\S+$/i.test(remote) || /^[\w.-]+@[\w.-]+:\S+$/.test(remote))) {
32
+ return Promise.reject(new Error('This repository has an unsupported clone URL.'));
33
+ }
34
+ if (!root || !existsSync(root)) return Promise.reject(new Error('No workspace folder is available. Add a scan folder in device settings.'));
35
+ if (typeof name !== 'string' || !name || name === '.' || name === '..' || name.startsWith('-') || /[/\\\x00-\x1f]/.test(name)) {
36
+ return Promise.reject(new Error('Invalid repository folder name.'));
37
+ }
38
+ const target = resolve(root, name);
39
+ if (dirname(target) !== resolve(root)) return Promise.reject(new Error('Invalid repository folder name.'));
40
+ const expectedKey = repoKey || normalizeRemote(remote)?.key;
41
+ if (!expectedKey || normalizeRemote(remote)?.key !== expectedKey) return Promise.reject(new Error('The clone URL belongs to a different repository.'));
42
+ return withRepositoryLock(target, async () => {
43
+ if (existsSync(target)) {
44
+ // Another session may have just cloned the same repo. Reuse only an
45
+ // exact identity match; never touch or remove an existing folder.
46
+ if (inspectCheckout(target)?.key === expectedKey) return target;
47
+ throw Object.assign(new Error(`${target} already exists. Use Locate existing to choose the correct checkout.`), { target });
48
+ }
49
+ mkdirSync(target); // exclusive reservation; cleanup only what we created
50
+ try {
51
+ await new Promise((accept, reject) => {
52
+ const child = spawnGit('git', ['clone', '--', remote, target], {
53
+ stdio: ['ignore', 'ignore', 'pipe'],
54
+ detached: process.platform !== 'win32',
55
+ env: { ...process.env, GIT_TERMINAL_PROMPT: '0', GIT_ASKPASS: 'echo', SSH_ASKPASS: 'echo',
56
+ GIT_SSH_COMMAND: process.env.GIT_SSH_COMMAND || 'ssh -o BatchMode=yes' },
57
+ });
58
+ let stderr = '';
59
+ let timedOut = false;
60
+ let killTimer;
61
+ const signalGit = (signal) => {
62
+ try {
63
+ // SSH/helpers must exit too before the directory can be cleaned.
64
+ if (child.pid && process.platform !== 'win32') process.kill(-child.pid, signal);
65
+ else child.kill(signal);
66
+ } catch { /* already exited */ }
67
+ };
68
+ child.stderr.on('data', (chunk) => { stderr = `${stderr}${chunk}`.slice(-4000); });
69
+ const timer = setTimeout(() => {
70
+ timedOut = true;
71
+ signalGit('SIGTERM');
72
+ killTimer = setTimeout(() => signalGit('SIGKILL'), 2000);
73
+ }, timeoutMs);
74
+ const finish = (err) => {
75
+ clearTimeout(timer);
76
+ clearTimeout(killTimer);
77
+ if (err) reject(err); else accept();
78
+ };
79
+ child.on('error', finish);
80
+ // Wait for exit before deleting the partial clone/releasing the lock.
81
+ child.on('close', (code) => {
82
+ const detail = stderr.trim().split('\n').filter(Boolean).slice(-2).join(' · ')
83
+ .replace(/(https?:\/\/)[^\s/@]+(?::[^\s/@]*)?@/gi, '$1');
84
+ finish(timedOut ? new Error('Cloning timed out. Check the network and try again.') : code === 0 ? null
85
+ : new Error(`Could not clone the repository${detail ? `: ${detail}` : '.'}`));
86
+ });
87
+ });
88
+ if (inspectCheckout(target)?.key !== expectedKey) throw new Error('The cloned repository does not match the requested repository.');
89
+ return target;
90
+ } catch (err) {
91
+ rmSync(target, { recursive: true, force: true });
92
+ err.target = target;
93
+ throw err;
94
+ }
95
+ });
96
+ }