diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 9f6a6bb22..a825b6f56 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -18,11 +18,30 @@ entry. See `CONTRIBUTING.md` § Releases & changelog. ## [Unreleased] +### Added — runtime install of subscription CLIs from the admin UI (#309 extension, enabler for #294) + +- **The Subscription-CLIs page can now install a missing vendor CLI in-app.** + The public image deliberately does not bundle the Claude/Codex/Gemini CLIs + (redistribution needs legal review); previously a missing CLI dead-ended in + manual shell steps. A new "Install now" button triggers an operator-side + `npm install` from the public registry into `CLI_TOOLS_DIR` (defaults to + `/cli-tools` on the persisted volume, so installs survive + restarts). Detection and the in-app login prefer that directory over PATH. +- **New routes** (auth-required, same router as the existing login flow): + `POST /api/v1/admin/cli-backends/:id/install` (202 accepted / 200 already + installed / 409 while another install runs / 400 unknown id or non-semver + version) and `GET /api/v1/admin/cli-backends/:id/install/status`. +- **Hardening:** package names only from a fixed allowlist, optional version + strictly semver-validated, `execFile` without a shell, bounded time/output, + host-global single-flight. New env vars documented in `middleware/.env.example`: + `CLI_TOOLS_DIR`, `CODEX_HOME`. + ### Fixed — core-decoupling zero floor no longer hides same-named files (#470 C13 review) - `scripts/check-core-decoupling.mjs` now excludes only the exact detector path `scripts/check-core-decoupling.mjs` instead of any basename match, closing the hole where a same-named file dropped under `middleware/src/` could hide Dev Platform identifiers from the permanent zero floor. A colocated regression test proves the detector stays self-excluded while a probe file at `middleware/src/__probe/check-core-decoupling.mjs` is counted. - The remaining human-readable fixture labels left behind by the C13 identifier rename now use the neutral example-plugin naming too (`Example Plugin` / `Beispiel-Plugin`), so the tests assert against the strings their fixtures actually define and no permanently-green "old assertion, new fixture" trap remains. - `middleware/test/auth/staticPublicPathsClosedSet.test.ts` still skips the loopback-listener half in restrictive local sandboxes, but if `CI` is set the same bind failure now throws with a clear message instead of silently skipping the five 401 assertions. + ### Added — migration handoff: a plugin can adopt an existing installation's schema (#470 C11) - **Plugins extracted out of core no longer re-apply core's migrations.** diff --git a/middleware/.env.example b/middleware/.env.example index e4d5d9010..50bc52e65 100644 --- a/middleware/.env.example +++ b/middleware/.env.example @@ -54,6 +54,18 @@ MEMORY_SEED_DIR=./seed/memory # missing | overwrite | skip MEMORY_SEED_MODE=missing +# CLI_TOOLS_DIR: npm --prefix used by the in-app "install CLI" action on the +# Subscription-CLIs admin page (runtime install of claude/codex/gemini — the +# public image deliberately does not bundle them). Leave UNSET in Docker/Fly: +# it defaults to /cli-tools on the persisted volume, so an +# installed CLI survives restarts. Local dev default: ./data/cli-tools. +# CLI_TOOLS_DIR=/data/cli-tools +# +# CODEX_HOME: where the OpenAI Codex CLI keeps auth.json after `codex login`. +# Point it at the persisted volume (mirrors CLAUDE_CONFIG_DIR) so a codex +# subscription login survives restarts. +# CODEX_HOME=/data/codex-cli + # --- Knowledge graph (Postgres + pgvector) ---------------------------------- # When DATABASE_URL is set, the middleware persists the knowledge + agentic # graph in Postgres (schema auto-migrates on startup), and the routines + diff --git a/middleware/src/platform/cliAuthService.ts b/middleware/src/platform/cliAuthService.ts index 12472b6df..24973672b 100644 --- a/middleware/src/platform/cliAuthService.ts +++ b/middleware/src/platform/cliAuthService.ts @@ -25,7 +25,12 @@ */ import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'; -import { scrubbedEnv, detectCliBackends, __resetCliBackendCache } from './cliBackendDetector.js'; +import { + scrubbedEnv, + detectCliBackends, + resolveCliBin, + __resetCliBackendCache, +} from './cliBackendDetector.js'; export type CliLoginStatus = 'pending' | 'authorized' | 'invalid' | 'expired' | 'error'; @@ -108,7 +113,9 @@ export async function startCliLogin(cliId: string): Promise { disposeActive(); - const child = spawn(backend.bin, ['auth', 'login', '--claudeai'], { + // Resolve through the runtime install dir so a CLI installed in-app is + // spawnable even when it is not on PATH. + const child = spawn(resolveCliBin(backend.bin), ['auth', 'login', '--claudeai'], { env: scrubbedEnv(), windowsHide: true, }); @@ -243,7 +250,7 @@ export async function cliLogout(cliId: string): Promise<{ ok: boolean }> { const backend = snap.backends.find((b) => b.id === cliId); if (!backend?.installed) return { ok: true }; await new Promise((resolve) => { - const c = spawn(backend.bin, ['auth', 'logout'], { env: scrubbedEnv(), windowsHide: true }); + const c = spawn(resolveCliBin(backend.bin), ['auth', 'logout'], { env: scrubbedEnv(), windowsHide: true }); c.on('error', () => resolve()); c.on('exit', () => resolve()); setTimeout(() => { diff --git a/middleware/src/platform/cliBackendDetector.ts b/middleware/src/platform/cliBackendDetector.ts index 52a5fde52..b1e182dcd 100644 --- a/middleware/src/platform/cliBackendDetector.ts +++ b/middleware/src/platform/cliBackendDetector.ts @@ -25,6 +25,8 @@ * "needs verification" and not yet recommended. */ import { execFile } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import path from 'node:path'; import { CLI_ENV_SCRUB_KEYS } from '@omadia/orchestrator'; /** Tri-state: we are honest about what a read-only probe can actually prove. */ @@ -48,6 +50,10 @@ export interface CliBackendStatus { readonly billing: CliBillingPosture; /** Short human note explaining the current state / next step. */ readonly detail: string; + /** Whether the in-app runtime install (`cliInstallService`) supports this CLI. */ + readonly installable: boolean; + /** Where the probed binary came from: the runtime install dir, or PATH. */ + readonly installedVia?: 'runtime' | 'path'; } interface CliBackendSpec { @@ -61,6 +67,8 @@ interface CliBackendSpec { /** Map a successful auth-probe result → login state + optional account. */ readonly parseAuth?: (out: string) => { state: CliLoginState; account?: string }; readonly billing: CliBillingPosture; + /** npm package the runtime installer may install for this backend. */ + readonly installPackage?: string; } /** @@ -97,6 +105,7 @@ const CLI_BACKENDS: ReadonlyArray = [ return { state: 'unknown' }; }, billing: 'subscription', + installPackage: '@anthropic-ai/claude-code', }, { id: 'codex', @@ -104,6 +113,7 @@ const CLI_BACKENDS: ReadonlyArray = [ bin: 'codex', versionArgs: ['--version'], billing: 'needs-verification', + installPackage: '@openai/codex', }, { id: 'gemini', @@ -111,9 +121,40 @@ const CLI_BACKENDS: ReadonlyArray = [ bin: 'gemini', versionArgs: ['--version'], billing: 'needs-verification', + installPackage: '@google/gemini-cli', }, ]; +/** + * Directory the runtime installer (`cliInstallService`) uses as its npm + * `--prefix`. Defaults under `PLATFORM_DATA_DIR` (e.g. the persisted `/data` + * volume on Fly) so installs survive machine restarts; local dev falls back to + * `data/cli-tools` under the working directory. + */ +export function cliToolsDir(): string { + const explicit = process.env['CLI_TOOLS_DIR']; + if (explicit) return explicit; + const dataDir = process.env['PLATFORM_DATA_DIR']; + return dataDir ? path.join(dataDir, 'cli-tools') : path.join(process.cwd(), 'data', 'cli-tools'); +} + +/** + * Resolve the binary to spawn for a backend: prefer the runtime install dir + * (`/bin/`), else the bare name via PATH. This is what makes + * a volume install visible to detection and login without mutating PATH. + * POSIX layout only — on Windows npm puts `.cmd` directly in the prefix, + * so there the PATH fallback is the effective path. Deployment is Linux. + */ +export function resolveCliBin(bin: string): string { + const candidate = path.join(cliToolsDir(), 'bin', bin); + return existsSync(candidate) ? candidate : bin; +} + +/** npm package for a backend id, or undefined when not runtime-installable. */ +export function getInstallPackage(cliId: string): string | undefined { + return CLI_BACKENDS.find((s) => s.id === cliId)?.installPackage; +} + const PROBE_TIMEOUT_MS = 4000; const MAX_OUTPUT_BYTES = 64 * 1024; @@ -186,7 +227,8 @@ function pickString(obj: Record, keys: readonly string[]): stri } async function detectOne(spec: CliBackendSpec): Promise { - const ver = await runProbe(spec.bin, spec.versionArgs); + const binPath = resolveCliBin(spec.bin); + const ver = await runProbe(binPath, spec.versionArgs); if (!ver.ok && ver.stderr === 'not found') { return { id: spec.id, @@ -196,6 +238,7 @@ async function detectOne(spec: CliBackendSpec): Promise { loggedIn: 'no', billing: spec.billing, detail: `${spec.bin} is not installed in this environment.`, + installable: Boolean(spec.installPackage), }; } @@ -204,7 +247,7 @@ async function detectOne(spec: CliBackendSpec): Promise { let loggedIn: CliLoginState = 'unknown'; let account: string | undefined; if (spec.authArgs && spec.parseAuth) { - const auth = await runProbe(spec.bin, spec.authArgs); + const auth = await runProbe(binPath, spec.authArgs); const parsed = spec.parseAuth(`${auth.stdout}\n${auth.stderr}`); loggedIn = parsed.state; account = parsed.account; @@ -229,6 +272,8 @@ async function detectOne(spec: CliBackendSpec): Promise { ...(account ? { account } : {}), billing: spec.billing, detail, + installable: Boolean(spec.installPackage), + installedVia: binPath === spec.bin ? 'path' : 'runtime', }; } diff --git a/middleware/src/platform/cliInstallService.ts b/middleware/src/platform/cliInstallService.ts new file mode 100644 index 000000000..e4dc2819f --- /dev/null +++ b/middleware/src/platform/cliInstallService.ts @@ -0,0 +1,207 @@ +/** + * Runtime install of vendor LLM CLIs (#309 extension, enabler for #294). + * + * The public Docker image deliberately does NOT bundle the vendor CLIs + * (`INSTALL_SUBSCRIPTION_CLIS=false` — redistributing proprietary CLIs needs + * legal review). This service closes the resulting dead end in the admin UI: + * an operator-triggered `npm install` from the public npm registry into a + * writable, persisted tools directory. Installing from the registry at the + * operator's request is distribution by npm, not redistribution by us. + * + * Hard rules (mirroring `cliBackendDetector`): + * - **No shell, no user input in argv.** The package name comes from a fixed + * allowlist keyed by backend id; an optional version is validated against a + * strict semver pattern before it may appear in the argv. + * - **Single-flight.** One install at a time, host-global (single sticky + * runtime) — a concurrent request is rejected, not queued. + * - **Bounded.** Hard timeout and capped output; the log tail is kept for the + * status endpoint so a failure is diagnosable from the UI. + * + * The install prefix is `CLI_TOOLS_DIR` (defaults under `PLATFORM_DATA_DIR`, + * e.g. the persisted `/data` volume on Fly) so an install survives machine + * restarts. `resolveCliBin` in the detector prefers this prefix over PATH. + */ +import { execFile } from 'node:child_process'; +import { mkdirSync } from 'node:fs'; +import path from 'node:path'; + +import { + cliToolsDir, + detectCliBackends, + getInstallPackage, + scrubbedEnv, + __resetCliBackendCache, +} from './cliBackendDetector.js'; + +export type CliInstallState = 'idle' | 'running' | 'succeeded' | 'failed'; + +export interface CliInstallStatus { + readonly cliId: string; + readonly status: CliInstallState; + readonly error?: string; + /** Last lines of npm output — enough to diagnose a failure from the UI. */ + readonly logTail?: string; + readonly startedAt?: number; + readonly finishedAt?: number; +} + +/** Backend id is not in the install allowlist. */ +export class UnknownCliBackendError extends Error {} +/** Another install is still running (single-flight). */ +export class CliInstallConflictError extends Error {} +/** The optional `version` field failed strict semver validation. */ +export class InvalidCliVersionError extends Error {} + +/** `1.2.3` or `1.2.3-tag.1` — nothing else may reach the npm argv. */ +const SEMVER_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z][0-9A-Za-z.-]*)?$/; +/** npm may resolve + download platform binaries; well below CI patience. */ +const INSTALL_TIMEOUT_MS = 5 * 60_000; +const MAX_OUTPUT_BYTES = 256 * 1024; +const LOG_TAIL_CHARS = 2000; + +interface InstallJob { + readonly cliId: string; + status: CliInstallState; + readonly startedAt: number; + finishedAt?: number; + error?: string; + logTail?: string; +} + +let current: InstallJob | undefined; + +type InstallRunner = ( + args: readonly string[], + env: NodeJS.ProcessEnv, +) => Promise<{ ok: boolean; output: string }>; + +/** Default runner: `npm ` — no shell, bounded time and output. */ +const npmRunner: InstallRunner = (args, env) => + new Promise((resolve) => { + execFile( + 'npm', + [...args], + { timeout: INSTALL_TIMEOUT_MS, maxBuffer: MAX_OUTPUT_BYTES, env, windowsHide: true }, + (err, stdout, stderr) => { + resolve({ ok: !err, output: `${String(stdout ?? '')}\n${String(stderr ?? '')}` }); + }, + ); + }); + +let runner: InstallRunner = npmRunner; +let detect: typeof detectCliBackends = detectCliBackends; + +/** + * Start installing a backend's CLI. Resolves as soon as the job is accepted + * (or found unnecessary) — the install itself runs in the background and is + * observed via {@link getCliInstallStatus}. + */ +export async function startCliInstall( + cliId: string, + version?: string, +): Promise<{ started: boolean; alreadyInstalled: boolean }> { + const pkg = getInstallPackage(cliId); + if (!pkg) { + throw new UnknownCliBackendError(`"${cliId}" cannot be installed from here.`); + } + if (version !== undefined && !SEMVER_RE.test(version)) { + throw new InvalidCliVersionError('version must be a plain semver like 1.2.3'); + } + if (current?.status === 'running') { + throw new CliInstallConflictError( + `An install of "${current.cliId}" is already running. Wait for it to finish.`, + ); + } + + // Reserve the single-flight slot BEFORE any await: the idempotency probe + // below yields for seconds, and two concurrent versionless requests passing + // the check above would otherwise both run `npm install -g` into the same + // prefix — which can corrupt the tree. + const job: InstallJob = { cliId, status: 'running', startedAt: Date.now() }; + current = job; + + // Idempotency: a backend that is already present needs no install. With an + // explicit version we still run npm (it is the authority on version moves). + if (version === undefined) { + let snap; + try { + snap = await detect({ force: true }); + } catch (err) { + current = undefined; + throw err; + } + if (snap.backends.find((b) => b.id === cliId)?.installed) { + current = undefined; + return { started: false, alreadyInstalled: true }; + } + } + + const dir = cliToolsDir(); + try { + mkdirSync(path.join(dir, '.npm-cache'), { recursive: true }); + } catch (err) { + current = undefined; // release the slot — nothing is running + throw err; + } + + const env: NodeJS.ProcessEnv = { + ...scrubbedEnv(), + npm_config_cache: path.join(dir, '.npm-cache'), + npm_config_update_notifier: 'false', + npm_config_fund: 'false', + npm_config_audit: 'false', + }; + + void runner(['install', '-g', '--prefix', dir, `${pkg}@${version ?? 'latest'}`], env) + .then(({ ok, output }) => { + job.logTail = output.trim().slice(-LOG_TAIL_CHARS); + job.finishedAt = Date.now(); + if (ok) { + job.status = 'succeeded'; + // The freshly installed binary must show up on the next detection. + // Guarded so a throw here can never retro-flip a success to 'failed' + // via the trailing catch. + try { + __resetCliBackendCache(); + } catch { + /* cache reset is best-effort */ + } + } else { + job.status = 'failed'; + job.error = 'npm install failed — see the log tail.'; + } + }) + .catch((err: unknown) => { + job.finishedAt = Date.now(); + job.status = 'failed'; + job.error = err instanceof Error ? err.message : String(err); + }); + + return { started: true, alreadyInstalled: false }; +} + +/** Current install state for a backend (host-global single-flight). */ +export function getCliInstallStatus(cliId: string): CliInstallStatus { + if (!current || current.cliId !== cliId) { + return { cliId, status: 'idle' }; + } + return { + cliId, + status: current.status, + ...(current.error ? { error: current.error } : {}), + ...(current.logTail ? { logTail: current.logTail } : {}), + startedAt: current.startedAt, + ...(current.finishedAt ? { finishedAt: current.finishedAt } : {}), + }; +} + +/** Test seams. */ +export function __setCliInstallRunner(fn: InstallRunner | undefined): void { + runner = fn ?? npmRunner; +} +export function __setCliInstallDetector(fn: typeof detectCliBackends | undefined): void { + detect = fn ?? detectCliBackends; +} +export function __resetCliInstallState(): void { + current = undefined; +} diff --git a/middleware/src/routes/adminCliBackends.ts b/middleware/src/routes/adminCliBackends.ts index d3e11ed17..eb40a9539 100644 --- a/middleware/src/routes/adminCliBackends.ts +++ b/middleware/src/routes/adminCliBackends.ts @@ -6,6 +6,8 @@ * for instead of a metered API key. * * GET / → { backends, generatedAt } (`?refresh=1` to bust cache) + * POST /:id/install → 202 { status:'started' } | 200 { alreadyInstalled } (runtime npm install) + * GET /:id/install/status → { status: idle|running|succeeded|failed, … } * POST /:id/login/start → { sessionId, verificationUrl } (spawns `claude auth login`) * POST /:id/login/code → { status, account? } (writes the pasted code to stdin) * POST /:id/login/cancel → { ok } @@ -25,6 +27,13 @@ import { cancelCliLogin, cliLogout, } from '../platform/cliAuthService.js'; +import { + startCliInstall, + getCliInstallStatus, + UnknownCliBackendError, + CliInstallConflictError, + InvalidCliVersionError, +} from '../platform/cliInstallService.js'; export function createAdminCliBackendsRouter(): Router { const router = Router(); @@ -39,6 +48,34 @@ export function createAdminCliBackendsRouter(): Router { } }); + router.post('/:id/install', async (req: Request, res: Response) => { + const body = (req.body ?? {}) as { version?: unknown }; + if (body.version !== undefined && typeof body.version !== 'string') { + res.status(400).json({ error: 'bad_request', message: 'version must be a string.' }); + return; + } + try { + const result = await startCliInstall(String(req.params['id']), body.version); + if (result.alreadyInstalled) { + res.json({ status: 'succeeded', alreadyInstalled: true }); + return; + } + res.status(202).json({ status: 'started' }); + } catch (err) { + if (err instanceof CliInstallConflictError) { + res.status(409).json({ error: 'install_in_progress', message: err.message }); + } else if (err instanceof UnknownCliBackendError || err instanceof InvalidCliVersionError) { + res.status(400).json({ error: 'bad_request', message: err.message }); + } else { + res.status(500).json({ error: 'install_failed', message: errMessage(err) }); + } + } + }); + + router.get('/:id/install/status', (req: Request, res: Response) => { + res.json(getCliInstallStatus(String(req.params['id']))); + }); + router.post('/:id/login/start', async (req: Request, res: Response) => { try { const result = await startCliLogin(String(req.params['id'])); diff --git a/middleware/test/cliInstallService.test.ts b/middleware/test/cliInstallService.test.ts new file mode 100644 index 000000000..76cdd9b0b --- /dev/null +++ b/middleware/test/cliInstallService.test.ts @@ -0,0 +1,297 @@ +/** + * Runtime CLI install (#309 extension, enabler for #294) — service + route. + * + * The npm runner and the detection snapshot are injected via the service's + * test seams, so nothing here touches the network, npm, or the host's real + * CLI installs (the detector tests deliberately do; these must not). + */ +import { describe, it, beforeEach, afterEach } from 'node:test'; +import { strict as assert } from 'node:assert'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import type { Server } from 'node:http'; +import type { AddressInfo } from 'node:net'; + +import express from 'express'; + +import { + startCliInstall, + getCliInstallStatus, + UnknownCliBackendError, + CliInstallConflictError, + InvalidCliVersionError, + __setCliInstallRunner, + __setCliInstallDetector, + __resetCliInstallState, +} from '../src/platform/cliInstallService.js'; +import { + cliToolsDir, + resolveCliBin, + getInstallPackage, + __resetCliBackendCache, +} from '../src/platform/cliBackendDetector.js'; +import { createAdminCliBackendsRouter } from '../src/routes/adminCliBackends.js'; +import { listenLoopback } from './_helpers/listenLoopback.js'; + +/** A detection snapshot with every backend absent (the fresh-container case). */ +const NOTHING_INSTALLED = { + backends: [ + { + id: 'codex', + label: 'Codex (OpenAI)', + bin: 'codex', + installed: false, + loggedIn: 'no' as const, + billing: 'needs-verification' as const, + detail: 'x', + installable: true, + }, + ], + generatedAt: 0, +}; + +async function waitForTerminal(cliId: string): Promise> { + for (let i = 0; i < 100; i++) { + const s = getCliInstallStatus(cliId); + if (s.status === 'succeeded' || s.status === 'failed') return s; + await new Promise((r) => setTimeout(r, 10)); + } + return getCliInstallStatus(cliId); +} + +describe('cliInstallService', () => { + let dir: string; + let prevToolsDir: string | undefined; + + beforeEach(() => { + dir = mkdtempSync(path.join(tmpdir(), 'cli-tools-')); + prevToolsDir = process.env['CLI_TOOLS_DIR']; + process.env['CLI_TOOLS_DIR'] = dir; + __setCliInstallDetector(async () => NOTHING_INSTALLED); + }); + + afterEach(() => { + __setCliInstallRunner(undefined); + __setCliInstallDetector(undefined); + __resetCliInstallState(); + __resetCliBackendCache(); + if (prevToolsDir === undefined) delete process.env['CLI_TOOLS_DIR']; + else process.env['CLI_TOOLS_DIR'] = prevToolsDir; + rmSync(dir, { recursive: true, force: true }); + }); + + it('rejects an id outside the allowlist — nothing user-controlled reaches npm', async () => { + await assert.rejects( + () => startCliInstall('lodash; curl evil'), + UnknownCliBackendError, + ); + }); + + it('rejects a non-semver version so it can never appear in the argv', async () => { + for (const bad of ['latest', '1.2', '1.2.3 --registry=http://evil', '$(reboot)', '1.2.3;x']) { + await assert.rejects(() => startCliInstall('codex', bad), InvalidCliVersionError); + } + }); + + it('short-circuits when detection says the backend is already installed', async () => { + __setCliInstallDetector(async () => ({ + ...NOTHING_INSTALLED, + backends: [{ ...NOTHING_INSTALLED.backends[0]!, installed: true }], + })); + let ran = false; + __setCliInstallRunner(async () => { + ran = true; + return { ok: true, output: '' }; + }); + const res = await startCliInstall('codex'); + assert.deepEqual(res, { started: false, alreadyInstalled: true }); + assert.equal(ran, false); + }); + + it('runs npm with the allowlisted package, the tools prefix, and the pinned version', async () => { + let seenArgs: readonly string[] = []; + __setCliInstallRunner(async (args) => { + seenArgs = args; + return { ok: true, output: 'added 1 package' }; + }); + const res = await startCliInstall('codex', '1.2.3'); + assert.deepEqual(res, { started: true, alreadyInstalled: false }); + const done = await waitForTerminal('codex'); + assert.equal(done.status, 'succeeded'); + assert.deepEqual(seenArgs, ['install', '-g', '--prefix', dir, '@openai/codex@1.2.3']); + }); + + it('a failed npm run surfaces status failed with a diagnosable log tail', async () => { + __setCliInstallRunner(async () => ({ ok: false, output: 'npm ERR! EAI_AGAIN registry' })); + await startCliInstall('codex', '1.2.3'); + const done = await waitForTerminal('codex'); + assert.equal(done.status, 'failed'); + assert.ok(done.error); + assert.match(done.logTail ?? '', /EAI_AGAIN/); + }); + + it('reserves the single-flight slot across the idempotency probe (no TOCTOU race)', async () => { + // Two concurrent VERSIONLESS starts: the second must conflict even while + // the first is still awaiting detection — otherwise both would run + // `npm install -g` into the same prefix. + let releaseDetect!: () => void; + const detectGate = new Promise((r) => { + releaseDetect = r; + }); + __setCliInstallDetector(async () => { + await detectGate; + return NOTHING_INSTALLED; + }); + __setCliInstallRunner(async () => ({ ok: true, output: '' })); + + const first = startCliInstall('codex'); + await assert.rejects(() => startCliInstall('codex'), CliInstallConflictError); + releaseDetect(); + assert.deepEqual(await first, { started: true, alreadyInstalled: false }); + const done = await waitForTerminal('codex'); + assert.equal(done.status, 'succeeded'); + }); + + it('releases the single-flight slot when the backend turns out to be installed', async () => { + __setCliInstallDetector(async () => ({ + ...NOTHING_INSTALLED, + backends: [{ ...NOTHING_INSTALLED.backends[0]!, installed: true }], + })); + const res = await startCliInstall('codex'); + assert.deepEqual(res, { started: false, alreadyInstalled: true }); + // Slot must be free again — a follow-up (versioned) install may start. + __setCliInstallRunner(async () => ({ ok: true, output: '' })); + const second = await startCliInstall('codex', '1.2.3'); + assert.deepEqual(second, { started: true, alreadyInstalled: false }); + }); + + it('is single-flight: a second start while one runs is a conflict', async () => { + let release!: () => void; + const gate = new Promise((r) => { + release = r; + }); + __setCliInstallRunner(async () => { + await gate; + return { ok: true, output: '' }; + }); + await startCliInstall('codex', '1.2.3'); + await assert.rejects(() => startCliInstall('codex', '1.2.3'), CliInstallConflictError); + release(); + const done = await waitForTerminal('codex'); + assert.equal(done.status, 'succeeded'); + }); + + it('status for a backend with no job is idle', () => { + assert.deepEqual(getCliInstallStatus('claude'), { cliId: 'claude', status: 'idle' }); + }); + + it('resolveCliBin prefers the tools dir over PATH only when the binary exists there', () => { + // Nothing installed in the fresh temp dir → bare name (PATH resolution). + assert.equal(resolveCliBin('codex'), 'codex'); + assert.equal(cliToolsDir(), dir); + }); + + it('every detector backend with an install package is exposed to the service', () => { + assert.equal(getInstallPackage('claude'), '@anthropic-ai/claude-code'); + assert.equal(getInstallPackage('codex'), '@openai/codex'); + assert.equal(getInstallPackage('gemini'), '@google/gemini-cli'); + assert.equal(getInstallPackage('nope'), undefined); + }); +}); + +describe('adminCliBackends install routes', () => { + let server: Server | undefined; + let dir: string; + let prevToolsDir: string | undefined; + + beforeEach(() => { + dir = mkdtempSync(path.join(tmpdir(), 'cli-tools-')); + prevToolsDir = process.env['CLI_TOOLS_DIR']; + process.env['CLI_TOOLS_DIR'] = dir; + __setCliInstallDetector(async () => NOTHING_INSTALLED); + }); + + afterEach(async () => { + __setCliInstallRunner(undefined); + __setCliInstallDetector(undefined); + __resetCliInstallState(); + __resetCliBackendCache(); + if (prevToolsDir === undefined) delete process.env['CLI_TOOLS_DIR']; + else process.env['CLI_TOOLS_DIR'] = prevToolsDir; + rmSync(dir, { recursive: true, force: true }); + if (server) { + await new Promise((resolve) => server!.close(() => resolve())); + server = undefined; + } + }); + + async function startServer(): Promise { + const app = express(); + app.use(express.json()); + app.use('/api/v1/admin/cli-backends', createAdminCliBackendsRouter()); + server = await listenLoopback(app); + return (server.address() as AddressInfo).port; + } + + it('POST /:id/install accepts with 202 and the status endpoint reaches succeeded', async () => { + __setCliInstallRunner(async () => ({ ok: true, output: 'added 1 package' })); + const port = await startServer(); + + const res = await fetch(`http://127.0.0.1:${port}/api/v1/admin/cli-backends/codex/install`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({}), + }); + assert.equal(res.status, 202); + assert.deepEqual(await res.json(), { status: 'started' }); + + await waitForTerminal('codex'); + const status = await fetch( + `http://127.0.0.1:${port}/api/v1/admin/cli-backends/codex/install/status`, + ); + assert.equal(status.status, 200); + const body = (await status.json()) as { status: string }; + assert.equal(body.status, 'succeeded'); + }); + + it('POST /:id/install with a running job answers 409', async () => { + let release!: () => void; + const gate = new Promise((r) => { + release = r; + }); + __setCliInstallRunner(async () => { + await gate; + return { ok: true, output: '' }; + }); + const port = await startServer(); + const url = `http://127.0.0.1:${port}/api/v1/admin/cli-backends/codex/install`; + + const first = await fetch(url, { method: 'POST' }); + assert.equal(first.status, 202); + const second = await fetch(url, { method: 'POST' }); + assert.equal(second.status, 409); + const body = (await second.json()) as { error: string }; + assert.equal(body.error, 'install_in_progress'); + release(); + }); + + it('POST /:id/install rejects unknown ids and malformed versions with 400', async () => { + const port = await startServer(); + const unknown = await fetch( + `http://127.0.0.1:${port}/api/v1/admin/cli-backends/definitely-not-a-cli/install`, + { method: 'POST' }, + ); + assert.equal(unknown.status, 400); + + const badVersion = await fetch( + `http://127.0.0.1:${port}/api/v1/admin/cli-backends/codex/install`, + { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ version: 'latest && curl evil' }), + }, + ); + assert.equal(badVersion.status, 400); + }); +}); diff --git a/web-ui/app/_lib/api.ts b/web-ui/app/_lib/api.ts index c767d9be2..8aa682995 100644 --- a/web-ui/app/_lib/api.ts +++ b/web-ui/app/_lib/api.ts @@ -477,6 +477,8 @@ export interface CliBackendStatus { account?: string; billing: CliBillingPosture; detail: string; + installable: boolean; + installedVia?: 'runtime' | 'path'; } export interface CliBackendsResponse { @@ -531,6 +533,30 @@ export async function cliLogout(id: string): Promise<{ ok: boolean }> { return postJson<{ ok: boolean }>(`/v1/admin/cli-backends/${encodeURIComponent(id)}/logout`, {}); } +export type CliInstallState = 'idle' | 'running' | 'succeeded' | 'failed'; + +export interface CliInstallStatus { + cliId: string; + status: CliInstallState; + error?: string; + logTail?: string; +} + +/** Trigger the runtime install of a backend's CLI (202 = started, poll status). */ +export async function startCliInstall(id: string): Promise<{ status: string; alreadyInstalled?: boolean }> { + return postJson<{ status: string; alreadyInstalled?: boolean }>( + `/v1/admin/cli-backends/${encodeURIComponent(id)}/install`, + {}, + ); +} + +/** Poll the state of a running (or finished) runtime install. */ +export async function getCliInstallStatus(id: string): Promise { + return getJson( + `/v1/admin/cli-backends/${encodeURIComponent(id)}/install/status`, + ); +} + // ----------------------------------------------------------------------------- // NDJSON helper — yields parsed JSON objects from a `application/x-ndjson` // stream. Tolerates LF and CRLF line endings, ignores blank lines, and diff --git a/web-ui/app/admin/subscription-clis/_components/InstallBox.tsx b/web-ui/app/admin/subscription-clis/_components/InstallBox.tsx new file mode 100644 index 000000000..1f47dbae3 --- /dev/null +++ b/web-ui/app/admin/subscription-clis/_components/InstallBox.tsx @@ -0,0 +1,185 @@ +'use client'; + +/** + * In-app runtime CLI install (#294 enabler) + the manual install steps. + * + * Extracted from `SubscriptionClisPanel.tsx` to keep that file within the + * workspace size rule. The public image does not bundle the vendor CLIs, so + * "not installed" used to dead-end in manual shell steps (OM-11). For + * installable backends `InstallBox` triggers the backend's npm install into + * the persisted tools dir and polls until it lands; the manual steps stay one + * click away for operators who prefer the terminal. + */ +import { useEffect, useState } from 'react'; +import type { useTranslations } from 'next-intl'; + +import { Button } from '../../../_components/ui/Button'; +import { + ApiError, + getCliInstallStatus, + startCliInstall, + type CliBackendStatus, +} from '../../../_lib/api'; + +type T = ReturnType; + +/** + * OM-11 — the "how do I get this CLI onto the server" steps. + * + * Rendered in three places: collapsed inside the connect box (CLI present, + * operator prefers the terminal), collapsed under the install button + * (installable CLI absent), and expanded on its own when the CLI is absent + * and not installable from here. + */ +export function ManualInstallSteps({ + b, + t, +}: { + b: CliBackendStatus; + t: T; +}): React.ReactElement { + return ( +
    +
  1. + {t('connect.step1')}{' '} + + {t('connect.installCmd')} + +
  2. +
  3. + {t('connect.step2')}{' '} + + {t('connect.loginCmd', { bin: b.bin })} + +
  4. +
  5. {t('connect.step3')}
  6. +
+ ); +} + +type InstallPhase = + | { phase: 'idle' } + | { phase: 'starting' } + | { phase: 'running' } + | { phase: 'failed'; detail?: string; logTail?: string }; + +/** How often the panel asks the backend whether a running install finished. */ +const INSTALL_POLL_INTERVAL_MS = 3000; +/** Stop polling after this many consecutive errors (e.g. an expired session). */ +const MAX_POLL_FAILURES = 5; + +export function InstallBox({ + b, + t, + onChanged, +}: { + b: CliBackendStatus; + t: T; + onChanged: () => void; +}): React.ReactElement { + const [phase, setPhase] = useState({ phase: 'idle' }); + + useEffect(() => { + if (phase.phase !== 'running') return; + let cancelled = false; + let failures = 0; + const timer = setInterval(() => { + void (async () => { + try { + const s = await getCliInstallStatus(b.id); + if (cancelled) return; + failures = 0; + if (s.status === 'succeeded') { + clearInterval(timer); + onChanged(); + } else if (s.status === 'failed') { + clearInterval(timer); + setPhase({ + phase: 'failed', + ...(s.error ? { detail: s.error } : {}), + ...(s.logTail ? { logTail: s.logTail } : {}), + }); + } else if (s.status === 'idle') { + // Backend restarted mid-install — offer the button again. + clearInterval(timer); + setPhase({ phase: 'idle' }); + } + } catch { + // Transient poll error — retry, but never poll a dead session forever. + failures += 1; + if (failures >= MAX_POLL_FAILURES && !cancelled) { + clearInterval(timer); + setPhase({ phase: 'failed' }); + } + } + })(); + }, INSTALL_POLL_INTERVAL_MS); + return () => { + cancelled = true; + clearInterval(timer); + }; + }, [phase.phase, b.id, onChanged]); + + const onInstall = async (): Promise => { + setPhase({ phase: 'starting' }); + try { + const res = await startCliInstall(b.id); + if (res.alreadyInstalled) { + onChanged(); + setPhase({ phase: 'idle' }); + } else { + setPhase({ phase: 'running' }); + } + } catch (err) { + // 409 = another install is running host-wide — say so, don't just "failed". + const detail = + err instanceof ApiError && err.status === 409 + ? t('install.conflict') + : err instanceof Error + ? err.message + : String(err); + setPhase({ phase: 'failed', detail }); + } + }; + + return ( +
+

{t('install.autoIntro')}

+ + {(phase.phase === 'idle' || phase.phase === 'failed') && ( +
+ +
+ )} + + {(phase.phase === 'starting' || phase.phase === 'running') && ( +

+ {t('install.installing')} +

+ )} + + {phase.phase === 'failed' && ( +
+

{t('install.failed')}

+ {phase.detail ? ( +

{phase.detail}

+ ) : null} + {phase.logTail ? ( +
+              {phase.logTail}
+            
+ ) : null} +
+ )} + +
+ + {t('install.manualSummary')} + + +
+
+ ); +} diff --git a/web-ui/app/admin/subscription-clis/_components/SubscriptionClisPanel.tsx b/web-ui/app/admin/subscription-clis/_components/SubscriptionClisPanel.tsx index 6a1d01011..53f46ff0a 100644 --- a/web-ui/app/admin/subscription-clis/_components/SubscriptionClisPanel.tsx +++ b/web-ui/app/admin/subscription-clis/_components/SubscriptionClisPanel.tsx @@ -22,6 +22,7 @@ import { type CliBackendsResponse, type CliBackendStatus, } from '../../../_lib/api'; +import { InstallBox, ManualInstallSteps } from './InstallBox'; type T = ReturnType; @@ -193,40 +194,6 @@ export function SubscriptionClisPanel({ ); } -/** - * OM-11 — the "how do I get this CLI onto the server" steps. - * - * Extracted so the SAME instructions can render in both states that need them: - * collapsed inside the connect box (CLI present, operator prefers the terminal) - * and expanded on its own when the CLI is absent, where it is the only thing - * the operator can act on. - */ -function ManualInstallSteps({ - b, - t, -}: { - b: CliBackendStatus; - t: T; -}): React.ReactElement { - return ( -
    -
  1. - {t('connect.step1')}{' '} - - {t('connect.installCmd')} - -
  2. -
  3. - {t('connect.step2')}{' '} - - {t('connect.loginCmd', { bin: b.bin })} - -
  4. -
  5. {t('connect.step3')}
  6. -
- ); -} - type LoginPhase = | { phase: 'idle' } | { phase: 'starting' } @@ -349,10 +316,14 @@ function CliRow({
{t('install.heading')}
-

- {t('install.intro')} -

- + {b.installable ? ( + + ) : ( + <> +

{t('install.intro')}

+ + + )} )} diff --git a/web-ui/app/admin/subscription-clis/_components/__tests__/SubscriptionClisPanel.test.tsx b/web-ui/app/admin/subscription-clis/_components/__tests__/SubscriptionClisPanel.test.tsx index 48dd41da0..4229149cb 100644 --- a/web-ui/app/admin/subscription-clis/_components/__tests__/SubscriptionClisPanel.test.tsx +++ b/web-ui/app/admin/subscription-clis/_components/__tests__/SubscriptionClisPanel.test.tsx @@ -1,4 +1,4 @@ -import { screen, waitFor } from '@testing-library/react'; +import { fireEvent, screen, waitFor } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { renderWithIntl } from '../../../../_lib/test-utils'; @@ -18,8 +18,10 @@ import type { CliBackendStatus } from '../../../../_lib/api'; * the wire and had zero render sites. */ -const { mockGetCliBackends } = vi.hoisted(() => ({ +const { mockGetCliBackends, mockStartCliInstall, mockGetCliInstallStatus } = vi.hoisted(() => ({ mockGetCliBackends: vi.fn(), + mockStartCliInstall: vi.fn(), + mockGetCliInstallStatus: vi.fn(), })); vi.mock('../../../../_lib/api', () => ({ @@ -28,6 +30,9 @@ vi.mock('../../../../_lib/api', () => ({ submitCliLoginCode: vi.fn(), cancelCliLogin: vi.fn(), cliLogout: vi.fn(), + startCliInstall: mockStartCliInstall, + getCliInstallStatus: mockGetCliInstallStatus, + ApiError: class MockApiError extends Error {}, })); function backend(over: Partial = {}): CliBackendStatus { @@ -110,4 +115,41 @@ describe('', () => { screen.getByText(/npm install -g @anthropic-ai\/claude-code/), ).toBeTruthy(); }); + + it('an uninstalled installable CLI offers the in-app install button (manual steps collapsed)', async () => { + mockGetCliBackends.mockResolvedValue({ + backends: [backend({ installed: false, installable: true })], + generatedAt: Date.now(), + }); + + renderWithIntl( {}} />, { + locale: 'de', + }); + + const button = await screen.findByRole('button', { name: /Jetzt installieren/ }); + expect(button).toBeTruthy(); + // The terminal path stays available, one click away. + expect(screen.getByText(/Lieber manuell installieren\?/)).toBeTruthy(); + }); + + it('clicking install triggers the backend job and shows progress', async () => { + mockGetCliBackends.mockResolvedValue({ + backends: [backend({ installed: false, installable: true })], + generatedAt: Date.now(), + }); + mockStartCliInstall.mockResolvedValue({ status: 'started' }); + mockGetCliInstallStatus.mockResolvedValue({ cliId: 'claude', status: 'running' }); + + renderWithIntl( {}} />, { + locale: 'de', + }); + + const button = await screen.findByRole('button', { name: /Jetzt installieren/ }); + fireEvent.click(button); + + await waitFor(() => { + expect(mockStartCliInstall).toHaveBeenCalledWith('claude'); + }); + expect(await screen.findByTestId('cli-install-running')).toBeTruthy(); + }); }); diff --git a/web-ui/messages/de.json b/web-ui/messages/de.json index 95c17b801..eb4d00dfe 100644 --- a/web-ui/messages/de.json +++ b/web-ui/messages/de.json @@ -2906,7 +2906,14 @@ "refreshed": "Gerade aktualisiert", "install": { "heading": "CLI installieren", - "intro": "Diese CLI ist auf diesem Server nicht vorhanden. Dort installieren und anmelden — danach steht die Anmeldung auch hier zur Verfügung." + "intro": "Diese CLI ist auf diesem Server nicht vorhanden. Dort installieren und anmelden — danach steht die Anmeldung auch hier zur Verfügung.", + "autoIntro": "Diese CLI ist auf diesem Server nicht vorhanden. Sie kann direkt von hier installiert werden — aus der öffentlichen npm-Registry in das persistente Datenverzeichnis des Servers.", + "button": "Jetzt installieren", + "installing": "Wird installiert … das kann einige Minuten dauern.", + "failed": "Installation fehlgeschlagen.", + "conflict": "Auf diesem Server läuft bereits eine CLI-Installation. Bitte warten, bis sie abgeschlossen ist, und dann erneut versuchen.", + "retry": "Erneut versuchen", + "manualSummary": "Lieber manuell installieren?" } }, "createIssue": { diff --git a/web-ui/messages/en.json b/web-ui/messages/en.json index 413d9ee5e..a140301f1 100644 --- a/web-ui/messages/en.json +++ b/web-ui/messages/en.json @@ -2906,7 +2906,14 @@ "refreshed": "Updated just now", "install": { "heading": "Install the CLI", - "intro": "This CLI is not present on this server. Install it there, then log in — afterwards the in-app login becomes available here." + "intro": "This CLI is not present on this server. Install it there, then log in — afterwards the in-app login becomes available here.", + "autoIntro": "This CLI is not present on this server. You can install it from here — it is fetched from the public npm registry into the server's persistent data directory.", + "button": "Install now", + "installing": "Installing… this can take a few minutes.", + "failed": "Installation failed.", + "conflict": "Another CLI install is already running on this server. Wait for it to finish, then try again.", + "retry": "Try again", + "manualSummary": "Prefer to install manually?" } }, "createIssue": {