Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
`<PLATFORM_DATA_DIR>/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.**
Expand Down
12 changes: 12 additions & 0 deletions middleware/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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 <PLATFORM_DATA_DIR>/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 +
Expand Down
13 changes: 10 additions & 3 deletions middleware/src/platform/cliAuthService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -108,7 +113,9 @@ export async function startCliLogin(cliId: string): Promise<StartLoginResult> {

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,
});
Expand Down Expand Up @@ -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<void>((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(() => {
Expand Down
49 changes: 47 additions & 2 deletions middleware/src/platform/cliBackendDetector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand All @@ -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 {
Expand All @@ -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;
}

/**
Expand Down Expand Up @@ -97,23 +105,56 @@ const CLI_BACKENDS: ReadonlyArray<CliBackendSpec> = [
return { state: 'unknown' };
},
billing: 'subscription',
installPackage: '@anthropic-ai/claude-code',
},
{
id: 'codex',
label: 'Codex (OpenAI)',
bin: 'codex',
versionArgs: ['--version'],
billing: 'needs-verification',
installPackage: '@openai/codex',
},
{
id: 'gemini',
label: 'Gemini (Google)',
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
* (`<cliToolsDir>/bin/<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 `<bin>.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;

Expand Down Expand Up @@ -186,7 +227,8 @@ function pickString(obj: Record<string, unknown>, keys: readonly string[]): stri
}

async function detectOne(spec: CliBackendSpec): Promise<CliBackendStatus> {
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,
Expand All @@ -196,6 +238,7 @@ async function detectOne(spec: CliBackendSpec): Promise<CliBackendStatus> {
loggedIn: 'no',
billing: spec.billing,
detail: `${spec.bin} is not installed in this environment.`,
installable: Boolean(spec.installPackage),
};
}

Expand All @@ -204,7 +247,7 @@ async function detectOne(spec: CliBackendSpec): Promise<CliBackendStatus> {
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;
Expand All @@ -229,6 +272,8 @@ async function detectOne(spec: CliBackendSpec): Promise<CliBackendStatus> {
...(account ? { account } : {}),
billing: spec.billing,
detail,
installable: Boolean(spec.installPackage),
installedVia: binPath === spec.bin ? 'path' : 'runtime',
};
}

Expand Down
Loading
Loading