@gleapai/kai-bridge 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/harnesses.mjs CHANGED
@@ -1,18 +1,4 @@
1
- // Harness registry: which coding agents this device can run, how to find
2
- // their binaries, install/update them, probe their login, and open a
3
- // login.
4
- //
5
- // claude — Claude Code. BUNDLED: the ACP adapter ships the Agent SDK,
6
- // which ships the CLI. Nothing to install; only a login.
7
- // codex — Codex CLI. BUNDLED by codex-acp. Only a login.
8
- // cursor — Cursor Agent CLI. NOT bundled (no npm package); installed by
9
- // the bridge from Cursor's release tarball into
10
- // ~/.kai/harnesses/cursor/<version>/ and called by path — never
11
- // via the vendor's ~/.local/bin/agent symlink (other vendors
12
- // use that name too: Grok's CLI on at least one dev machine).
13
- //
14
- // "Installed" therefore means: claude/codex always; cursor when the
15
- // tarball is present. "Signed in" is per profile (see profiles.mjs).
1
+ // Coding agents: bundled defaults, optional managed updates, and per-profile sign-in.
16
2
 
17
3
  import { execFileSync, spawn } from "node:child_process";
18
4
  import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs";
@@ -20,6 +6,7 @@ import { arch, homedir, platform } from "node:os";
20
6
  import { dirname, join, resolve as resolvePath } from "node:path";
21
7
  import { fileURLToPath } from "node:url";
22
8
  import { createRequire } from 'node:module';
9
+ import { managedHarnessRoot, installManagedHarness } from './harness-install.mjs';
23
10
 
24
11
  const PKG = join(dirname(fileURLToPath(import.meta.url)), "..");
25
12
  const PKG_BIN = join(PKG, "node_modules", ".bin");
@@ -76,12 +63,12 @@ export function cursorDownloadUrl(version = CURSOR_AGENT_VERSION) {
76
63
  }
77
64
 
78
65
  /** Bundled / installed binary for a harness, or null. */
79
- export function harnessBinary(harness, kaiHome = join(homedir(), ".kai")) {
66
+ function packageBinary(harness, packageRequire) {
80
67
  if (harness === "claude") {
81
68
  try {
82
69
  // Resolve from the SDK exactly as the ACP adapter does, including nested
83
70
  // npm installs and the correct libc. Directory order is not a platform check.
84
- const sdkRequire = createRequire(require.resolve('@anthropic-ai/claude-agent-sdk'));
71
+ const sdkRequire = createRequire(packageRequire.resolve('@anthropic-ai/claude-agent-sdk'));
85
72
  const libc = platform() === 'linux' && !process.report.getReport().header.glibcVersionRuntime ? '-musl' : '';
86
73
  return sdkRequire.resolve(`@anthropic-ai/claude-agent-sdk-${platform()}-${arch()}${libc}/claude${platform() === 'win32' ? '.exe' : ''}`);
87
74
  } catch {
@@ -94,15 +81,25 @@ export function harnessBinary(harness, kaiHome = join(homedir(), ".kai")) {
94
81
  if (platform() === 'win32') {
95
82
  // execFile/ACP cannot execute a JS entrypoint or .cmd shim directly on
96
83
  // Windows. Resolve the same pinned package's native executable there.
97
- const codexRequire = createRequire(require.resolve('@openai/codex/package.json'));
84
+ const codexRequire = createRequire(packageRequire.resolve('@openai/codex/package.json'));
98
85
  const root = dirname(codexRequire.resolve(`@openai/codex-win32-${arch()}/package.json`));
99
86
  const triple = `${arch() === 'arm64' ? 'aarch64' : 'x86_64'}-pc-windows-msvc`;
100
87
  const binary = join(root, 'vendor', triple, 'bin', 'codex.exe');
101
88
  return existsSync(binary) ? binary : null;
102
89
  }
103
- return require.resolve('@openai/codex/bin/codex.js');
90
+ return packageRequire.resolve('@openai/codex/bin/codex.js');
104
91
  } catch { return null; }
105
92
  }
93
+ return null;
94
+ }
95
+
96
+ export function harnessBinary(harness, kaiHome = process.env.KAI_HOME || join(homedir(), ".kai")) {
97
+ const managed = managedHarnessRoot(kaiHome, harness);
98
+ if (managed) {
99
+ const binary = packageBinary(harness, createRequire(join(managed, "package.json")));
100
+ if (binary && existsSync(binary)) return binary;
101
+ }
102
+ if (harness === "claude" || harness === "codex") return packageBinary(harness, require);
106
103
  if (harness === "cursor") {
107
104
  const bin = join(CURSOR_CURRENT(kaiHome), "dist-package", platform() === "win32" ? "cursor-agent.exe" : "cursor-agent");
108
105
  return existsSync(bin) ? bin : null;
@@ -146,18 +143,22 @@ export function describeHarnesses(kaiHome) {
146
143
  }
147
144
 
148
145
  /**
149
- * Install (or update) a harness. Bundled ones are no-ops. Cursor:
146
+ * Install (or update) a coding agent. Cursor:
150
147
  * download the release tarball for this platform into
151
148
  * ~/.kai/harnesses/cursor/<version>/ and point `current` at it.
152
149
  * `onLog(line)` receives progress; resolves `{ ok, version, binary }`.
153
150
  */
154
- export async function installHarness(harness, { kaiHome, onLog = () => {}, version = CURSOR_AGENT_VERSION } = {}) {
151
+ export async function installHarness(harness, { kaiHome = process.env.KAI_HOME || join(homedir(), ".kai"), onLog = () => {}, version, update = false } = {}) {
155
152
  if (!HARNESS_IDS.includes(harness)) throw new Error(`unknown harness ${harness}`);
156
153
  if (HARNESS_INFO[harness].bundled) {
157
154
  const bin = harnessBinary(harness, kaiHome);
158
- onLog(`${HARNESS_INFO[harness].label} is bundled with the bridge (${bin ? versionOf(bin) : "missing — run npm install"})`);
159
- return { ok: !!bin, version: bin ? versionOf(bin) : null, binary: bin };
155
+ const currentVersion = bin ? versionOf(bin) : null;
156
+ if (!update && !version && currentVersion) return { ok: true, version: currentVersion, binary: bin };
157
+ return installManagedHarness({ harness, kaiHome, version: version || 'latest', onLog,
158
+ resolveBinary: root => packageBinary(harness, createRequire(join(root, 'package.json'))), probeVersion: versionOf });
160
159
  }
160
+ version ||= CURSOR_AGENT_VERSION;
161
+ if (!/^[0-9][a-zA-Z0-9.-]*$/.test(version)) throw new Error("Invalid Cursor version.");
161
162
  const root = CURSOR_ROOT(kaiHome);
162
163
  const target = join(root, version);
163
164
  const tmp = join(root, `.tmp-${version}-${process.pid}`);
@@ -197,7 +198,11 @@ export async function installHarness(harness, { kaiHome, onLog = () => {}, versi
197
198
  }
198
199
 
199
200
  /** Interactive login command for a harness under a profile's config dir. */
200
- export function harnessLoginCommand(harness, configDir, kaiHome) {
201
+ export function isHeadless(env = process.env, os = platform()) {
202
+ return !!(env.SSH_CONNECTION || env.SSH_TTY || (os === 'linux' && !env.DISPLAY && !env.WAYLAND_DISPLAY));
203
+ }
204
+
205
+ export function harnessLoginCommand(harness, configDir, kaiHome, { deviceAuth = isHeadless() } = {}) {
201
206
  const bin = harnessBinary(harness, kaiHome);
202
207
  if (!bin) return null;
203
208
  if (harness === "claude") {
@@ -207,7 +212,7 @@ export function harnessLoginCommand(harness, configDir, kaiHome) {
207
212
  const env = resolvePath(configDir) !== resolvePath(claudeDefault) ? { CLAUDE_CONFIG_DIR: configDir } : {};
208
213
  return { cmd: bin, args: ["auth", "login"], env };
209
214
  }
210
- if (harness === "codex") return { cmd: bin, args: ["login"], env: { CODEX_HOME: configDir } };
215
+ if (harness === "codex") return { cmd: bin, args: ["login", ...(deviceAuth ? ["--device-auth"] : [])], env: { CODEX_HOME: configDir } };
211
216
  return { cmd: bin, args: ["login"], env: {} };
212
217
  }
213
218
 
@@ -1,5 +1,5 @@
1
1
  // Reserve before boot: two sessions can otherwise choose the same free port.
2
- export class HostedPortPool {
2
+ export class PreviewPortPool {
3
3
  constructor() { this.owners = new Map(); this.tail = Promise.resolve(); }
4
4
  reserve(owner, preferred, pinned, listening) {
5
5
  const operation = this.tail.then(async () => {
@@ -17,4 +17,4 @@ export class HostedPortPool {
17
17
  }
18
18
  releaseSession(sessionId) { for (const [port, owner] of this.owners) if (owner.startsWith(`${sessionId}/`)) this.owners.delete(port); }
19
19
  }
20
- export const hostedPorts = new HostedPortPool();
20
+ export const previewPorts = new PreviewPortPool();
package/src/preview.mjs CHANGED
@@ -1,5 +1,4 @@
1
- import { hostedPorts } from './hosted-ports.mjs';
2
- import { boundHostedProcess } from './hosted-resources.mjs';
1
+ import { previewPorts } from './preview-ports.mjs';
3
2
  // Preview tier A — run the app's real dev servers next to the session.
4
3
  //
5
4
  // Each repo may commit a `.gleap/dev.yaml`:
@@ -389,7 +388,6 @@ function lockfileHash(cwd) {
389
388
 
390
389
  /** Runs `cmd` through the user's login shell (darwin) or the platform shell, capturing output into `fd`. */
391
390
  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; }
393
391
  if (process.platform !== "win32") cmd = withDaemonNode(cmd);
394
392
  if (process.platform === "darwin") {
395
393
  // Under launchd the daemon's PATH is frozen at install time
@@ -400,7 +398,6 @@ function spawnShell(cmd, { cwd, env, fd, detached = false }) {
400
398
  // Windows has no process groups to kill and `detached` would open a
401
399
  // console window — taskkill /T does the tree.
402
400
  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
401
  return child;
405
402
  }
406
403
 
@@ -419,12 +416,11 @@ export class ServiceRunner {
419
416
  * fires when a service that had become ready dies on its own;
420
417
  * `onProcess(name, pid, "add" | "remove")` lets the daemon persist pids.
421
418
  */
422
- constructor({ kaiHome, sessionId, log = () => {}, onStatus = () => {}, preferredPort = null, describeListener = null, settleMs = DEFAULT_SETTLE_MS, onServiceExit = () => {}, onProcess = () => {}, home = homedir(), registerPublicService = null } = {}) {
419
+ constructor({ kaiHome, sessionId, log = () => {}, onStatus = () => {}, preferredPort = null, describeListener = null, settleMs = DEFAULT_SETTLE_MS, onServiceExit = () => {}, onProcess = () => {}, home = homedir() } = {}) {
423
420
  if (settleMs === DEFAULT_SETTLE_MS) settleMs = defaultSettleMs();
424
421
  this.kaiHome = kaiHome;
425
422
  this.sessionId = sessionId;
426
- this.registerPublicService = registerPublicService;
427
- this.publicUrls = {};
423
+ this.serviceUrls = {};
428
424
  this.log = log;
429
425
  this.onStatus = onStatus;
430
426
  this.preferredPort = typeof preferredPort === "function" ? preferredPort : null;
@@ -479,13 +475,10 @@ export class ServiceRunner {
479
475
  }
480
476
  if (this.ports[svc.name]) continue;
481
477
  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
- }
478
+ const owner = `${this.sessionId}/${repoRoot}/${svc.name}`;
479
+ const pinned = !!declared && (mode === 'local' || new RegExp(`(^|[^0-9])${declared}([^0-9]|$)`).test(`${svc.run} ${Object.values(svc.env).join(' ')}`));
487
480
  if (declared && !(await isPortListening(declared))) {
488
- this.ports[svc.name] = declared;
481
+ this.ports[svc.name] = await previewPorts.reserve(owner, declared, pinned, isPortListening);
489
482
  continue;
490
483
  }
491
484
  if (declared) {
@@ -513,10 +506,11 @@ export class ServiceRunner {
513
506
  });
514
507
  }
515
508
  }
516
- this.ports[svc.name] = (declared ? await findFreePortNear(declared) : await this.stablePort(repoKey, svc.name)) ?? (await getFreePort());
509
+ const preferred = (declared ? await findFreePortNear(declared) : await this.stablePort(repoKey, svc.name)) ?? (await getFreePort());
510
+ this.ports[svc.name] = await previewPorts.reserve(owner, preferred, false, isPortListening);
517
511
  }
518
512
  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]}`;
513
+ this.serviceUrls[svc.name] = `${svc.protocol || 'http'}://localhost:${this.ports[svc.name]}`;
520
514
  }
521
515
  this.registered.set(repoRoot, services);
522
516
  this.previewNames ??= new Map();
@@ -594,7 +588,7 @@ export class ServiceRunner {
594
588
  const env = {
595
589
  ...process.env,
596
590
  PORT: String(port),
597
- ...Object.fromEntries(Object.entries(svc.env).map(([k, v]) => [k, substitutePorts(v, this.ports, this.publicUrls)])),
591
+ ...Object.fromEntries(Object.entries(svc.env).map(([k, v]) => [k, substitutePorts(v, this.ports, this.serviceUrls)])),
598
592
  KAI_SESSION_ID: String(this.sessionId),
599
593
  BROWSER: "none",
600
594
  };
@@ -603,7 +597,7 @@ export class ServiceRunner {
603
597
  // straight into `command not found` was the #1 preview failure. The
604
598
  // install is authoritative: when it fails, the dev command never runs.
605
599
  await this.ensureDeps(cwd, { env, fd, logPath, service: name, repoKey });
606
- const cmd = stripOpenFlag(substitutePorts(svc.run, this.ports, this.publicUrls));
600
+ const cmd = stripOpenFlag(substitutePorts(svc.run, this.ports, this.serviceUrls));
607
601
  const child = spawnShell(cmd, { cwd, env, fd, detached: true });
608
602
  m.ready = false;
609
603
  child.on("exit", (code) => {
@@ -865,7 +859,7 @@ export class ServiceRunner {
865
859
  }
866
860
 
867
861
  stopAll() {
868
- if (process.env.KAI_HOSTED === '1') hostedPorts.releaseSession(this.sessionId);
862
+ previewPorts.releaseSession(this.sessionId);
869
863
  for (const [name, child] of this.processes) {
870
864
  this.stopping.add(name);
871
865
  if (process.platform === "win32") {
package/src/profiles.mjs CHANGED
@@ -13,7 +13,7 @@
13
13
 
14
14
  import { execFileSync, spawn } from "node:child_process";
15
15
  import { cpSync, existsSync, mkdirSync } from "node:fs";
16
- import { HARNESS_IDS as REGISTRY_IDS, harnessBinary, harnessLoginCommand, probeHarnessAuth } from "./harnesses.mjs";
16
+ import { HARNESS_IDS as REGISTRY_IDS, harnessBinary, harnessLoginCommand, isHeadless, probeHarnessAuth } from "./harnesses.mjs";
17
17
  import { homedir } from "node:os";
18
18
  import { dirname, join, resolve } from "node:path";
19
19
  import { fileURLToPath } from "node:url";
@@ -112,8 +112,8 @@ export function createManagedProfile(harness, profileId, kaiHome) {
112
112
  }
113
113
 
114
114
  /** Interactive login for a profile (opens the harness's own flow). */
115
- export function loginCommand(harness, configDir, kaiHome = process.env.KAI_HOME || join(HOME, ".kai")) {
116
- const c = harnessLoginCommand(harness, configDir, kaiHome);
115
+ export function loginCommand(harness, configDir, kaiHome = process.env.KAI_HOME || join(HOME, ".kai"), options) {
116
+ const c = harnessLoginCommand(harness, configDir, kaiHome, options);
117
117
  if (!c) return null;
118
118
  return { cmd: c.cmd, args: c.args, env: { ...process.env, ...c.env } };
119
119
  }
@@ -124,8 +124,9 @@ export function loginCommand(harness, configDir, kaiHome = process.env.KAI_HOME
124
124
  * DEVICE running the login under the profile's config dir. Returns the
125
125
  * spawned process or null when no terminal could be opened.
126
126
  */
127
- export function openLoginTerminal(harness, configDir) {
128
- const c = loginCommand(harness, configDir);
127
+ export function openLoginTerminal(harness, configDir, kaiHome) {
128
+ if (isHeadless()) return null;
129
+ const c = loginCommand(harness, configDir, kaiHome);
129
130
  if (!c) return null;
130
131
  // Ambient claude must log in with the env untouched so credentials land
131
132
  // in the keychain, where the (equally untouched) probe and turns look.
package/src/service.mjs CHANGED
@@ -70,7 +70,7 @@ export function displayEnv(env = process.env) {
70
70
  export function renderSystemdUnit({ program, args, env = {} }) {
71
71
  const q = (s) => `"${String(s).replace(/"/g, '\\"')}"`;
72
72
  return `[Unit]
73
- Description=Gleap Kai Bridge
73
+ Description=Kai Code Bridge
74
74
  Wants=network-online.target
75
75
  After=network-online.target
76
76
 
package/src/setup.mjs CHANGED
@@ -165,7 +165,7 @@ const rowLine = (h) => {
165
165
  export async function runSetup({ binPath, prompter = makePrompter() } = {}) {
166
166
  const config = loadConfig();
167
167
  out("");
168
- out("── Kai Bridge ────────────────────────────────────────────────");
168
+ out("── Kai Code Bridge ────────────────────────────────────────────────");
169
169
  out("Run Kai Code sessions on this machine — with your own Claude /");
170
170
  out("Codex / Cursor login, against your local checkouts.");
171
171
  out("");
package/src/workspace.mjs CHANGED
@@ -15,7 +15,6 @@
15
15
  import { execFileSync } from "node:child_process";
16
16
  import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
17
17
  import { dirname, join } from "node:path";
18
- import { createHash } from 'node:crypto';
19
18
 
20
19
  import { seedNodeModules } from "./deps.mjs";
21
20
 
@@ -24,18 +23,13 @@ function git(cwd, args, opts = {}) {
24
23
  }
25
24
 
26
25
  export function sessionSlug(sessionId, title) {
27
- if (process.env.KAI_HOSTED === '1') {
28
- if (!/^[a-zA-Z0-9_-]{1,128}$/.test(String(sessionId))) throw new Error('Invalid session identity.');
29
- return `session-${sessionId}`;
30
- }
31
26
  const t = String(title || "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 32);
32
27
  const id = String(sessionId || "").slice(-8);
33
28
  return t ? `${t}-${id}` : `session-${id}`;
34
29
  }
35
30
 
36
31
  export function worktreePath(kaiHome, repoName, slug) {
37
- const repo = process.env.KAI_HOSTED === '1' ? createHash('sha256').update(String(repoName)).digest('hex') : repoName;
38
- return join(kaiHome, "worktrees", repo, slug);
32
+ return join(kaiHome, "worktrees", repoName, slug);
39
33
  }
40
34
 
41
35
  /**
@@ -176,22 +170,9 @@ export function discardChanges(cwd) {
176
170
  export function removeWorktree({ kaiHome, repo, sessionId, title }) {
177
171
  const dir = worktreePath(kaiHome, repo.name, sessionSlug(sessionId, title));
178
172
  if (!existsSync(dir)) return false;
179
- if (process.env.KAI_HOSTED === '1') {
180
- // Never destroy dirty or unpushed work during session cleanup.
181
- if (collectChanges(dir).files.length || git(dir, ['log', '--branches', '--not', '--remotes', '--oneline'])) return false;
182
- git(repo.primaryPath, ['worktree', 'remove', dir]);
183
- return true;
184
- }
185
- try {
186
- git(repo.primaryPath, ["worktree", "remove", "--force", dir]);
187
- } catch {
188
- rmSync(dir, { recursive: true, force: true });
189
- try {
190
- git(repo.primaryPath, ["worktree", "prune"]);
191
- } catch {
192
- /* best-effort */
193
- }
194
- }
173
+ // Never destroy dirty or unpushed work during session cleanup.
174
+ if (collectChanges(dir).files.length || git(dir, ['log', 'HEAD', '--not', '--remotes', '--oneline'])) return false;
175
+ git(repo.primaryPath, ['worktree', 'remove', dir]);
195
176
  return true;
196
177
  }
197
178
 
package/fly/Dockerfile DELETED
@@ -1,11 +0,0 @@
1
- FROM node:24-bookworm-slim
2
- RUN apt-get update && apt-get install -y --no-install-recommends git ca-certificates curl sudo openssh-client lsof procps supervisor xvfb fluxbox x11vnc novnc websockify python3 make g++ && rm -rf /var/lib/apt/lists/*
3
- RUN useradd --home-dir /data/home/kai --shell /bin/bash kai && printf 'kai ALL=(ALL) NOPASSWD:ALL\n' > /etc/sudoers.d/kai && chmod 440 /etc/sudoers.d/kai
4
- WORKDIR /opt/kai-bridge
5
- COPY package.json package-lock.json ./
6
- RUN npm ci --ignore-scripts
7
- COPY . .
8
- ENV PLAYWRIGHT_BROWSERS_PATH=/opt/playwright
9
- RUN node scripts/postinstall.mjs && node scripts/runtime-smoke.mjs && chmod +x src/codex-broker-client.mjs src/hosted-git.mjs fly/runtime-cli.sh fly/entrypoint.sh fly/login-codex.sh && for cli in codex claude git; do ln -s /opt/kai-bridge/fly/runtime-cli.sh /usr/local/bin/$cli; done && npx playwright install --with-deps chromium
10
- ENV KAI_HOSTED=1 KAI_HOME=/data/home/kai/.kai HOME=/data/home/kai KAI_BRIDGE_NO_SELF_UPDATE=1 DISABLE_AUTOUPDATER=1
11
- ENTRYPOINT ["/opt/kai-bridge/fly/entrypoint.sh"]
package/fly/UPDATES.md DELETED
@@ -1,76 +0,0 @@
1
- # Hosted coding runtime releases
2
-
3
- One Bridge release owns the complete coding runtime: Codex CLI, Claude Agent SDK
4
- (including its native Claude Code binary), both ACP adapters, Bridge and its
5
- runner. Versions are pinned in package.json and package-lock.json. The image no
6
- longer installs separate global CLIs. `codex`, `claude`, login, version probes,
7
- coding turns and the supervised Codex process all resolve this bundle.
8
-
9
- ## Routine updates
10
-
11
- Dependabot checks npm dependencies daily and groups the coding runtime changes.
12
- The `Coding runtime compatibility` workflow runs the full Bridge tests, native
13
- CLI versions, Codex app-server and both ACP initialization checks, package checks
14
- and the Linux image build. Vendor CLI updates are disabled for the managed
15
- executables so they cannot mutate a tested bundle underneath active sessions.
16
-
17
- After review and internal qualification, publish a new Bridge version and promote
18
- that exact version to the npm `hosted-stable` dist-tag. Promotion is a release
19
- operation, never something a customer VM decides from upstream `latest` versions.
20
- The channel is intentionally separate from `latest`, which connected devices use.
21
- No tag is published or promoted merely by adding this workflow.
22
-
23
- `npm pack`/`npm publish` generate a published npm-shrinkwrap.json from the committed
24
- lockfile. Consumers therefore receive the dependency tree that was tested. The
25
- temporary shrinkwrap is removed after packing. Always bump the Bridge version and
26
- refresh its lockfile for a new release; npm versions are immutable.
27
-
28
- Each hosted machine checks `hosted-stable` on a normal start, at most once per day.
29
- It does not wake a stopped machine to check. Updates only happen **before
30
- supervisord starts**, under a file lock; running coding, validation, previews,
31
- maintenance and login processes are never hot-replaced. A machine that remains
32
- running applies updates on its next normal stop/start.
33
-
34
- The updater downloads an exact release, checks protocol compatibility, installs
35
- the shrinkwrapped dependencies with lifecycle scripts disabled, and runs native
36
- startup checks with an empty temporary home. It selects the new bundle atomically
37
- only after those checks pass. Registry failures, invalid releases, failed checks
38
- and interrupted installs preserve the previous selection. Failed versions are
39
- quarantined; the next distinct approved version can still update.
40
-
41
- Runtime files are stored under `/opt/kai-runtime`. User repositories, worktrees,
42
- home directories and authentication remain on `/data` and are never copied or
43
- overwritten by updates. The current and one previous installed bundle are kept.
44
- This does not rebuild the VM, reset its usage allowance, or require a new login.
45
- Setup/update running time still counts against the slot allowance.
46
-
47
- ## Rollback and support
48
-
49
- Inspect `/opt/kai-runtime/state.json` for the active version, previous version,
50
- last check, failed version and update error. Bridge's normal version/harness
51
- reports show the selected executable versions in Gleap. Updates can be disabled
52
- with the machine environment `KAI_HOSTED_RUNTIME_UPDATES=0`.
53
-
54
- If a runtime passes startup checks but later proves incompatible, schedule a
55
- rollback through maintenance SSH:
56
-
57
- ```sh
58
- sudo node /opt/kai-bridge/fly/runtime-update.mjs --rollback-next-start
59
- ```
60
-
61
- Then stop and start the machine through Gleap. This selects the retained previous
62
- bundle (or the image's original bundle), preserves credentials/files and
63
- quarantines the rejected release. The command does not interrupt current work.
64
- Do not run `npm update` inside active release directories.
65
-
66
- The startup checks exercise real binaries and protocol initialization without
67
- account credentials or billable prompts. They do not replace authenticated
68
- two-session coding/refresh/verification tests on the internal cohort before
69
- promoting `hosted-stable`. A startup pass cannot guarantee compatibility with
70
- every provider-side behavior. Keep the previous runtime available for rollback.
71
-
72
- Changing the bootstrap protocol, OS packages, browser revision or persistent data formats requires
73
- an explicit image/migration release; the boot updater rejects unsupported
74
- protocol versions and Playwright revisions that differ from the image's installed
75
- browser. System modifications survive normal starts as configured by
76
- Fly root-filesystem persistence.
@@ -1,5 +0,0 @@
1
- import { startCodexBroker } from '../src/codex-broker.mjs';
2
- import { mkdirSync } from 'node:fs';
3
- mkdirSync('/data/home/kai/.codex', { recursive: true });
4
- const broker = startCodexBroker();
5
- process.on('SIGTERM', () => { broker.close(); process.exit(0); });
package/fly/entrypoint.sh DELETED
@@ -1,12 +0,0 @@
1
- #!/bin/sh
2
- set -eu
3
- mkdir -p /data/home/kai/.codex /data/home/kai/.claude /data/repos /data/logs
4
- chown kai:kai /data/home/kai /data/home/kai/.codex /data/home/kai/.claude /data/repos /data/logs
5
- touch /run/kai-runtime-update.lock
6
- chown root:kai /run/kai-runtime-update.lock
7
- chmod 660 /run/kai-runtime-update.lock
8
- # The lock and boot boundary keep installations away from coding/login/preview
9
- # processes. Failed or interrupted updates keep the previous validated bundle.
10
- KAI_RUNTIME_ROOT="$(flock /run/kai-runtime-update.lock node /opt/kai-bridge/fly/runtime-update.mjs)"
11
- export KAI_RUNTIME_ROOT
12
- exec /usr/bin/supervisord -n -c /opt/kai-bridge/fly/supervisord.conf
@@ -1,9 +0,0 @@
1
- #!/bin/sh
2
- set -eu
3
- # Pause the single refresh owner while native device authentication updates it.
4
- exec 9>/data/codex-login.lock
5
- flock -n 9 || { echo 'Another Codex sign-in is already in progress.'; exit 1; }
6
- control='/opt/kai-bridge/fly/supervisord.conf'
7
- supervisorctl -c "$control" stop codex-auth
8
- trap 'supervisorctl -c "$control" start codex-auth' EXIT
9
- sudo -iu kai codex login --device-auth
@@ -1,15 +0,0 @@
1
- #!/usr/bin/env node
2
- import { basename, join } from 'node:path';
3
- import { pathToFileURL } from 'node:url';
4
- import { spawn } from 'node:child_process';
5
- import { runtimeRoot } from './runtime-update.mjs';
6
-
7
- const root = process.env.KAI_RUNTIME_ROOT || runtimeRoot();
8
- const name = process.env.KAI_RUNTIME_CLI_NAME || basename(process.argv[1]).replace(/\.mjs$/, '');
9
- const { harnessBinary } = await import(pathToFileURL(join(root, 'src/harnesses.mjs')));
10
- const command = name === 'git' ? process.execPath : harnessBinary(name);
11
- if (!command) { process.stderr.write('The selected Kai runtime is missing this executable.\n'); process.exit(1); }
12
- const args = name === 'git' ? [join(root, 'src/hosted-git.mjs'), ...process.argv.slice(2)] : process.argv.slice(2);
13
- const child = spawn(command, args, { stdio: 'inherit', env: { ...process.env, KAI_RUNTIME_ROOT: root, DISABLE_AUTOUPDATER: '1' } });
14
- child.on('error', () => process.exit(1));
15
- child.on('exit', (code, signal) => { if (signal) process.kill(process.pid, signal); else process.exit(code ?? 1); });
@@ -1,10 +0,0 @@
1
- #!/bin/sh
2
- set -eu
3
- KAI_RUNTIME_CLI_NAME="${0##*/}"
4
- export KAI_RUNTIME_CLI_NAME
5
- # Startup validation has an isolated HOME and already holds the exclusive lock.
6
- if [ "${KAI_RUNTIME_UPDATE_CHECK:-0}" = '1' ]; then
7
- exec node /opt/kai-bridge/fly/runtime-cli.mjs "$@"
8
- fi
9
- # A native CLI opened through maintenance SSH waits for an in-progress update.
10
- exec flock --shared /run/kai-runtime-update.lock node /opt/kai-bridge/fly/runtime-cli.mjs "$@"
@@ -1,8 +0,0 @@
1
- import { join } from 'node:path';
2
- import { pathToFileURL } from 'node:url';
3
- import { runtimeRoot } from './runtime-update.mjs';
4
- const entry = { bridge: 'start.mjs', 'codex-auth': 'codex-auth.mjs' }[process.argv[2]];
5
- if (!entry) throw new Error('Unknown hosted runtime service.');
6
- const root = process.env.KAI_RUNTIME_ROOT || runtimeRoot();
7
- process.env.KAI_RUNTIME_ROOT = root;
8
- await import(pathToFileURL(join(root, 'fly', entry)));
@@ -1,137 +0,0 @@
1
- import { execFile } from 'node:child_process';
2
- import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from 'node:fs';
3
- import { join, resolve } from 'node:path';
4
- import { fileURLToPath } from 'node:url';
5
- import { promisify } from 'node:util';
6
- import { isNewer } from '../src/selfupdate.mjs';
7
-
8
- const exec = promisify(execFile);
9
- export const BASE_RUNTIME = '/opt/kai-bridge';
10
- export const RUNTIME_STORE = '/opt/kai-runtime';
11
- const CHANNEL = 'https://registry.npmjs.org/@gleapai/kai-bridge/hosted-stable';
12
- const DAY = 86400_000;
13
- const version = value => typeof value === 'string' && /^\d+\.\d+\.\d+$/.test(value);
14
- const read = path => { try { return JSON.parse(readFileSync(path, 'utf8')); } catch { return {}; } };
15
- const packageAt = root => read(join(root, 'package.json'));
16
- export function browserVersion(root) {
17
- const lock = read(join(root, existsSync(join(root, 'npm-shrinkwrap.json')) ? 'npm-shrinkwrap.json' : 'package-lock.json'));
18
- return lock.packages?.['node_modules/playwright-core']?.version;
19
- }
20
-
21
- export function assertImageCompatibility(candidate, base) {
22
- // Chromium is installed by the image, outside the replaceable runtime. Never
23
- // activate a new Playwright revision against the old browser executable.
24
- if (!browserVersion(candidate) || browserVersion(candidate) !== browserVersion(base)) {
25
- throw Object.assign(new Error('This release needs a browser image update.'), { code: 'image_required' });
26
- }
27
- }
28
-
29
- export function runtimeRoot(store = RUNTIME_STORE, base = BASE_RUNTIME) {
30
- const state = read(join(store, 'state.json'));
31
- const candidate = version(state.active) ? join(store, 'releases', state.active) : base;
32
- return existsSync(join(candidate, 'package.json')) ? candidate : base;
33
- }
34
-
35
- function save(store, state) {
36
- mkdirSync(store, { recursive: true });
37
- const temporary = join(store, `state.${process.pid}.tmp`);
38
- writeFileSync(temporary, JSON.stringify(state, null, 2) + '\n', { mode: 0o644 });
39
- renameSync(temporary, join(store, 'state.json'));
40
- }
41
-
42
- export function supervisorRunning() {
43
- const pid = Number(readFileText('/run/supervisord.pid'));
44
- if (!Number.isSafeInteger(pid) || pid <= 1) return false;
45
- try { process.kill(pid, 0); return true; } catch { return false; }
46
- }
47
- const readFileText = path => { try { return readFileSync(path, 'utf8').trim(); } catch { return ''; } };
48
-
49
- export function requestRuntimeRollback(store = RUNTIME_STORE) {
50
- const state = read(join(store, 'state.json'));
51
- if (!state.active || !state.previousVersion) throw new Error('No previous runtime is available.');
52
- writeFileSync(join(store, 'rollback-next-start'), '1\n', { mode: 0o600 });
53
- }
54
-
55
- export async function fetchHostedRelease() {
56
- const response = await fetch(CHANNEL, { signal: AbortSignal.timeout(8000), headers: { accept: 'application/json' } });
57
- if (response.status === 404) return null; // Channel has not been promoted yet.
58
- if (!response.ok) throw new Error(`Release registry unavailable (${response.status}).`);
59
- return response.json();
60
- }
61
-
62
- export async function installHostedRelease(release, destination, store, base = BASE_RUNTIME) {
63
- mkdirSync(destination, { recursive: true });
64
- const home = join(destination, '.install-home'); mkdirSync(home);
65
- // No customer HOME, OAuth profiles, bootstrap token or npm login is inherited.
66
- const env = { PATH: process.env.PATH, HOME: home, CI: '1', KAI_RUNTIME_UPDATE_CHECK: '1', npm_config_registry: 'https://registry.npmjs.org', npm_config_cache: join(store, 'cache') };
67
- try {
68
- const packed = await exec('npm', ['pack', `@gleapai/kai-bridge@${release.version}`, '--ignore-scripts', '--json', '--pack-destination', destination], { env, timeout: 30_000, maxBuffer: 1024 * 1024 });
69
- const archive = JSON.parse(packed.stdout)?.[0]?.filename;
70
- if (archive !== `gleapai-kai-bridge-${release.version}.tgz`) throw new Error('Unexpected runtime archive.');
71
- await exec('tar', ['-xzf', join(destination, archive), '--strip-components=1', '-C', destination], { timeout: 10_000 });
72
- const pkg = packageAt(destination), lock = read(join(destination, 'npm-shrinkwrap.json'));
73
- if (pkg.name !== '@gleapai/kai-bridge' || pkg.version !== release.version || pkg.kaiHostedRuntime?.protocol !== 1 || lock.version !== pkg.version) throw new Error('Release lacks a matching locked runtime.');
74
- assertImageCompatibility(destination, base);
75
- await exec('npm', ['ci', '--ignore-scripts', '--omit=dev', '--no-audit', '--no-fund'], { cwd: destination, env, timeout: 60_000, maxBuffer: 1024 * 1024 });
76
- await exec(process.execPath, [join(destination, 'scripts/runtime-smoke.mjs')], { cwd: destination, env, timeout: 45_000, maxBuffer: 1024 * 1024 });
77
- rmSync(join(destination, archive), { force: true });
78
- } finally { rmSync(home, { recursive: true, force: true }); }
79
- }
80
-
81
- /** Called under flock BEFORE supervisord starts. Running VMs never hot-swap
82
- * runtimes. Install/check failures and interrupted installs retain the old root. */
83
- export async function updateHostedRuntime({ store = RUNTIME_STORE, base = BASE_RUNTIME, now = Date.now(), busy = supervisorRunning(), enabled = true,
84
- fetchRelease = fetchHostedRelease, install = installHostedRelease, log = message => process.stderr.write(`${message}\n`) } = {}) {
85
- const current = runtimeRoot(store, base);
86
- if (busy) return current;
87
- const state = read(join(store, 'state.json'));
88
- if (existsSync(join(store, 'rollback-next-start')) && state.active && state.previousVersion) {
89
- save(store, { checkedAt: now, active: state.previous || null, failed: state.active, rolledBackAt: now });
90
- rmSync(join(store, 'rollback-next-start'), { force: true });
91
- log('Previous hosted runtime selected; the rejected release is quarantined.');
92
- return runtimeRoot(store, base);
93
- }
94
- if (!enabled) return current;
95
- if (state.checkedAt && now - state.checkedAt < DAY) return current;
96
- state.checkedAt = now; save(store, state);
97
- let release, staging;
98
- try {
99
- release = await fetchRelease();
100
- if (!release || release.name !== '@gleapai/kai-bridge' || release.kaiHostedRuntime?.protocol !== 1 || !version(release.version)) return current;
101
- if (!isNewer(release.version, packageAt(current).version) || state.failed === release.version) return current;
102
- staging = join(store, 'releases', `.staging-${release.version}`);
103
- rmSync(staging, { recursive: true, force: true });
104
- await install(release, staging, store, base);
105
- // Recheck admission boundary before activation, including manual invocation.
106
- if (supervisorRunning()) throw new Error('Machine became busy during the update.');
107
- const destination = join(store, 'releases', release.version);
108
- rmSync(destination, { recursive: true, force: true });
109
- renameSync(staging, destination);
110
- save(store, { checkedAt: now, active: release.version, previous: state.active || null, previousVersion: packageAt(current).version, activatedAt: now });
111
- // Keep one rollback bundle. User repositories and auth are outside this store.
112
- try {
113
- for (const entry of readdirSync(join(store, 'releases'))) {
114
- if (version(entry) && entry !== release.version && entry !== state.active) rmSync(join(store, 'releases', entry), { recursive: true, force: true });
115
- }
116
- rmSync(join(store, 'cache'), { recursive: true, force: true });
117
- } catch { /* Cleanup cannot invalidate an already-validated activation. */ }
118
- log(`Hosted runtime ${release.version} validated and selected for this start.`);
119
- return destination;
120
- } catch (error) {
121
- if (staging) rmSync(staging, { recursive: true, force: true });
122
- save(store, { ...state, ...(release?.version && version(release.version) ? { failed: release.version } : {}), error: error.code === 'image_required' ? 'This release needs a browser image update; previous runtime retained.' : 'Runtime update failed; previous release retained.' });
123
- log('Hosted runtime update failed; starting the previous release.');
124
- return current;
125
- }
126
- }
127
-
128
- if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
129
- if (process.argv.includes('--rollback-next-start')) {
130
- requestRuntimeRollback(); process.stdout.write('Rollback scheduled. Stop and start the machine from Gleap to apply it.\n');
131
- } else {
132
- // stdout is consumed by the entrypoint: emit only the selected absolute path.
133
- updateHostedRuntime({ enabled: process.env.KAI_HOSTED_RUNTIME_UPDATES !== '0' })
134
- .then(root => process.stdout.write(`${root}\n`))
135
- .catch(() => process.stdout.write(`${runtimeRoot()}\n`));
136
- }
137
- }