@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/fly/Dockerfile +11 -0
- package/fly/UPDATES.md +76 -0
- package/fly/codex-auth.mjs +5 -0
- package/fly/entrypoint.sh +12 -0
- package/fly/login-codex.sh +9 -0
- package/fly/runtime-cli.mjs +15 -0
- package/fly/runtime-cli.sh +10 -0
- package/fly/runtime-launch.mjs +8 -0
- package/fly/runtime-update.mjs +137 -0
- package/fly/start.mjs +7 -0
- package/fly/supervisord.conf +45 -0
- package/npm-shrinkwrap.json +2196 -0
- package/package.json +19 -7
- package/runner/lib/acp/harnesses.mjs +6 -1
- package/scripts/runtime-smoke.mjs +60 -0
- package/src/codex-broker-client.mjs +16 -0
- package/src/codex-broker.mjs +85 -0
- package/src/daemon.mjs +64 -62
- package/src/executor.mjs +22 -4
- package/src/harnesses.mjs +20 -9
- package/src/hosted-auth.mjs +42 -0
- package/src/hosted-git-credential.mjs +24 -0
- package/src/hosted-git.mjs +33 -0
- package/src/hosted-ports.mjs +20 -0
- package/src/hosted-resources.mjs +35 -0
- package/src/hosted-tunnel.mjs +76 -0
- package/src/hosted.mjs +86 -0
- package/src/preview.mjs +45 -9
- package/src/profiles.mjs +5 -2
- package/src/repository-setup.mjs +96 -0
- package/src/workspace.mjs +14 -2
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
93
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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}${
|
|
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
|
-
|
|
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
|
+
}
|