diff --git a/apps/server/src/git/Errors.ts b/apps/server/src/git/Errors.ts new file mode 100644 index 000000000000..15bf482f7bfe --- /dev/null +++ b/apps/server/src/git/Errors.ts @@ -0,0 +1,67 @@ +import { Schema } from "effect"; + +/** + * GitCommandError - Git command execution failed. + */ +export class GitCommandError extends Schema.TaggedErrorClass()("GitCommandError", { + operation: Schema.String, + command: Schema.String, + cwd: Schema.String, + detail: Schema.String, + cause: Schema.optional(Schema.Defect), +}) { + override get message(): string { + return `Git command failed in ${this.operation}: ${this.command} (${this.cwd}) - ${this.detail}`; + } +} + +/** + * GitHubCliError - GitHub CLI execution or authentication failed. + */ +export class GitHubCliError extends Schema.TaggedErrorClass()("GitHubCliError", { + operation: Schema.String, + detail: Schema.String, + cause: Schema.optional(Schema.Defect), +}) { + override get message(): string { + return `GitHub CLI failed in ${this.operation}: ${this.detail}`; + } +} + +/** + * TextGenerationError - Commit or PR text generation failed. + */ +export class TextGenerationError extends Schema.TaggedErrorClass()( + "TextGenerationError", + { + operation: Schema.String, + detail: Schema.String, + cause: Schema.optional(Schema.Defect), + }, +) { + override get message(): string { + return `Text generation failed in ${this.operation}: ${this.detail}`; + } +} + +/** + * GitManagerError - Stacked Git workflow orchestration failed. + */ +export class GitManagerError extends Schema.TaggedErrorClass()("GitManagerError", { + operation: Schema.String, + detail: Schema.String, + cause: Schema.optional(Schema.Defect), +}) { + override get message(): string { + return `Git manager failed in ${this.operation}: ${this.detail}`; + } +} + +/** + * GitManagerServiceError - Errors emitted by stacked Git workflow orchestration. + */ +export type GitManagerServiceError = + | GitManagerError + | GitCommandError + | GitHubCliError + | TextGenerationError; diff --git a/apps/server/src/git/isRepo.ts b/apps/server/src/git/isRepo.ts new file mode 100644 index 000000000000..6faf3e99c77b --- /dev/null +++ b/apps/server/src/git/isRepo.ts @@ -0,0 +1,6 @@ +import { existsSync } from "node:fs"; +import { join } from "node:path"; + +export function isGitRepository(cwd: string): boolean { + return existsSync(join(cwd, ".git")); +} diff --git a/apps/server/src/orchestration/projectMetadataProjection.ts b/apps/server/src/orchestration/projectMetadataProjection.ts new file mode 100644 index 000000000000..4a15b5af5575 --- /dev/null +++ b/apps/server/src/orchestration/projectMetadataProjection.ts @@ -0,0 +1,96 @@ +import type { OrchestrationEvent } from "@t3tools/contracts"; +import { Effect, Option } from "effect"; + +import type { ProjectionRepositoryError } from "../persistence/Errors.ts"; +import type { ProjectionProjectRepositoryShape } from "../persistence/Services/ProjectionProjects.ts"; +import type { ProjectionStateRepositoryShape } from "../persistence/Services/ProjectionState.ts"; + +export type ProjectMetadataOrchestrationEvent = Extract< + OrchestrationEvent, + { type: "project.created" | "project.meta-updated" | "project.deleted" } +>; + +export const PROJECT_METADATA_SNAPSHOT_PROJECTORS = [ + "projection.projects", + "projection.threads", + "projection.thread-messages", + "projection.thread-proposed-plans", + "projection.thread-activities", + "projection.thread-sessions", + "projection.checkpoints", +] as const; + +export const applyProjectMetadataProjection = (input: { + readonly event: ProjectMetadataOrchestrationEvent; + readonly projectionProjectRepository: ProjectionProjectRepositoryShape; +}): Effect.Effect => + Effect.gen(function* () { + switch (input.event.type) { + case "project.created": + yield* input.projectionProjectRepository.upsert({ + projectId: input.event.payload.projectId, + title: input.event.payload.title, + workspaceRoot: input.event.payload.workspaceRoot, + defaultModelSelection: input.event.payload.defaultModelSelection, + scripts: input.event.payload.scripts, + createdAt: input.event.payload.createdAt, + updatedAt: input.event.payload.updatedAt, + deletedAt: null, + }); + break; + + case "project.meta-updated": { + const existingRow = yield* input.projectionProjectRepository.getById({ + projectId: input.event.payload.projectId, + }); + if (Option.isSome(existingRow)) { + yield* input.projectionProjectRepository.upsert({ + ...existingRow.value, + ...(input.event.payload.title !== undefined + ? { title: input.event.payload.title } + : {}), + ...(input.event.payload.workspaceRoot !== undefined + ? { workspaceRoot: input.event.payload.workspaceRoot } + : {}), + ...(input.event.payload.defaultModelSelection !== undefined + ? { defaultModelSelection: input.event.payload.defaultModelSelection } + : {}), + ...(input.event.payload.scripts !== undefined + ? { scripts: input.event.payload.scripts } + : {}), + updatedAt: input.event.payload.updatedAt, + }); + } + break; + } + + case "project.deleted": { + const existingRow = yield* input.projectionProjectRepository.getById({ + projectId: input.event.payload.projectId, + }); + if (Option.isSome(existingRow)) { + yield* input.projectionProjectRepository.upsert({ + ...existingRow.value, + deletedAt: input.event.payload.deletedAt, + updatedAt: input.event.payload.deletedAt, + }); + } + break; + } + } + }); + +export const advanceProjectMetadataSnapshotState = (input: { + readonly event: ProjectMetadataOrchestrationEvent; + readonly projectionStateRepository: ProjectionStateRepositoryShape; +}): Effect.Effect => + Effect.forEach( + PROJECT_METADATA_SNAPSHOT_PROJECTORS, + (projector) => + input.projectionStateRepository.upsert({ + projector, + lastAppliedSequence: input.event.sequence, + updatedAt: input.event.occurredAt, + }), + { concurrency: 1 }, + ).pipe(Effect.asVoid); diff --git a/apps/server/src/projectFaviconRoute.test.ts b/apps/server/src/projectFaviconRoute.test.ts new file mode 100644 index 000000000000..d3c6e1a9b624 --- /dev/null +++ b/apps/server/src/projectFaviconRoute.test.ts @@ -0,0 +1,182 @@ +import fs from "node:fs"; +import http from "node:http"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, describe, expect, it } from "vitest"; +import { tryHandleProjectFaviconRequest } from "./projectFaviconRoute"; + +interface HttpResponse { + statusCode: number; + contentType: string | null; + body: string; +} + +const tempDirs: string[] = []; + +function makeTempDir(prefix: string): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + tempDirs.push(dir); + return dir; +} + +async function withRouteServer(run: (baseUrl: string) => Promise): Promise { + const server = http.createServer((req, res) => { + const url = new URL(req.url ?? "/", "http://127.0.0.1"); + if (tryHandleProjectFaviconRequest(url, res)) { + return; + } + res.writeHead(404, { "Content-Type": "text/plain" }); + res.end("Not Found"); + }); + + await new Promise((resolve, reject) => { + server.listen(0, "127.0.0.1", (error?: Error) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); + + const address = server.address(); + if (typeof address !== "object" || address === null) { + throw new Error("Expected server address to be an object"); + } + const baseUrl = `http://127.0.0.1:${address.port}`; + + try { + await run(baseUrl); + } finally { + await new Promise((resolve, reject) => { + server.close((error?: Error) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); + } +} + +async function request(baseUrl: string, pathname: string): Promise { + const response = await fetch(`${baseUrl}${pathname}`); + return { + statusCode: response.status, + contentType: response.headers.get("content-type"), + body: await response.text(), + }; +} + +describe("tryHandleProjectFaviconRequest", () => { + afterEach(() => { + for (const dir of tempDirs.splice(0, tempDirs.length)) { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + it("returns 400 when cwd is missing", async () => { + await withRouteServer(async (baseUrl) => { + const response = await request(baseUrl, "/api/project-favicon"); + expect(response.statusCode).toBe(400); + expect(response.body).toBe("Missing cwd parameter"); + }); + }); + + it("serves a well-known favicon file from the project root", async () => { + const projectDir = makeTempDir("t3code-favicon-route-root-"); + fs.writeFileSync(path.join(projectDir, "favicon.svg"), "favicon", "utf8"); + + await withRouteServer(async (baseUrl) => { + const pathname = `/api/project-favicon?cwd=${encodeURIComponent(projectDir)}`; + const response = await request(baseUrl, pathname); + expect(response.statusCode).toBe(200); + expect(response.contentType).toContain("image/svg+xml"); + expect(response.body).toBe("favicon"); + }); + }); + + it("resolves icon href from source files when no well-known favicon exists", async () => { + const projectDir = makeTempDir("t3code-favicon-route-source-"); + const iconPath = path.join(projectDir, "public", "brand", "logo.svg"); + fs.mkdirSync(path.dirname(iconPath), { recursive: true }); + fs.writeFileSync( + path.join(projectDir, "index.html"), + '', + ); + fs.writeFileSync(iconPath, "brand", "utf8"); + + await withRouteServer(async (baseUrl) => { + const pathname = `/api/project-favicon?cwd=${encodeURIComponent(projectDir)}`; + const response = await request(baseUrl, pathname); + expect(response.statusCode).toBe(200); + expect(response.contentType).toContain("image/svg+xml"); + expect(response.body).toBe("brand"); + }); + }); + + it("resolves icon link when href appears before rel in HTML", async () => { + const projectDir = makeTempDir("t3code-favicon-route-html-order-"); + const iconPath = path.join(projectDir, "public", "brand", "logo.svg"); + fs.mkdirSync(path.dirname(iconPath), { recursive: true }); + fs.writeFileSync( + path.join(projectDir, "index.html"), + '', + ); + fs.writeFileSync(iconPath, "brand-html-order", "utf8"); + + await withRouteServer(async (baseUrl) => { + const pathname = `/api/project-favicon?cwd=${encodeURIComponent(projectDir)}`; + const response = await request(baseUrl, pathname); + expect(response.statusCode).toBe(200); + expect(response.contentType).toContain("image/svg+xml"); + expect(response.body).toBe("brand-html-order"); + }); + }); + + it("resolves object-style icon metadata when href appears before rel", async () => { + const projectDir = makeTempDir("t3code-favicon-route-obj-order-"); + const iconPath = path.join(projectDir, "public", "brand", "obj.svg"); + fs.mkdirSync(path.dirname(iconPath), { recursive: true }); + fs.mkdirSync(path.join(projectDir, "src"), { recursive: true }); + fs.writeFileSync( + path.join(projectDir, "src", "root.tsx"), + 'const links = [{ href: "/brand/obj.svg", rel: "icon" }];', + "utf8", + ); + fs.writeFileSync(iconPath, "brand-obj-order", "utf8"); + + await withRouteServer(async (baseUrl) => { + const pathname = `/api/project-favicon?cwd=${encodeURIComponent(projectDir)}`; + const response = await request(baseUrl, pathname); + expect(response.statusCode).toBe(200); + expect(response.contentType).toContain("image/svg+xml"); + expect(response.body).toBe("brand-obj-order"); + }); + }); + + it("serves a fallback favicon when no icon exists", async () => { + const projectDir = makeTempDir("t3code-favicon-route-fallback-"); + + await withRouteServer(async (baseUrl) => { + const pathname = `/api/project-favicon?cwd=${encodeURIComponent(projectDir)}`; + const response = await request(baseUrl, pathname); + expect(response.statusCode).toBe(200); + expect(response.contentType).toContain("image/svg+xml"); + expect(response.body).toContain('data-fallback="project-favicon"'); + }); + }); + + it("returns 204 when fallback=none and no icon exists", async () => { + const projectDir = makeTempDir("t3code-favicon-route-no-fallback-"); + + await withRouteServer(async (baseUrl) => { + const pathname = `/api/project-favicon?cwd=${encodeURIComponent(projectDir)}&fallback=none`; + const response = await request(baseUrl, pathname); + expect(response.statusCode).toBe(204); + expect(response.body).toBe(""); + }); + }); +}); diff --git a/apps/server/src/projectFaviconRoute.ts b/apps/server/src/projectFaviconRoute.ts new file mode 100644 index 000000000000..18407398f60c --- /dev/null +++ b/apps/server/src/projectFaviconRoute.ts @@ -0,0 +1,184 @@ +import fs from "node:fs"; +import http from "node:http"; +import path from "node:path"; + +const FAVICON_MIME_TYPES: Record = { + ".png": "image/png", + ".jpg": "image/jpeg", + ".svg": "image/svg+xml", + ".ico": "image/x-icon", +}; + +const FALLBACK_FAVICON_SVG = ``; + +// Well-known favicon paths checked in order. +const FAVICON_CANDIDATES = [ + "favicon.svg", + "favicon.ico", + "favicon.png", + "public/favicon.svg", + "public/favicon.ico", + "public/favicon.png", + "app/favicon.ico", + "app/favicon.png", + "app/icon.svg", + "app/icon.png", + "app/icon.ico", + "src/favicon.ico", + "src/favicon.svg", + "src/app/favicon.ico", + "src/app/icon.svg", + "src/app/icon.png", + "assets/icon.svg", + "assets/icon.png", + "assets/logo.svg", + "assets/logo.png", +]; + +// Files that may contain a or icon metadata declaration. +const ICON_SOURCE_FILES = [ + "index.html", + "public/index.html", + "app/routes/__root.tsx", + "src/routes/__root.tsx", + "app/root.tsx", + "src/root.tsx", + "src/index.html", +]; + +// Matches tags or object-like icon metadata where rel/href can appear in any order. +const LINK_ICON_HTML_RE = + /]*\brel=["'](?:icon|shortcut icon)["'])(?=[^>]*\bhref=["']([^"'?]+))[^>]*>/i; +const LINK_ICON_OBJ_RE = + /(?=[^}]*\brel\s*:\s*["'](?:icon|shortcut icon)["'])(?=[^}]*\bhref\s*:\s*["']([^"'?]+))[^}]*/i; + +function extractIconHref(source: string): string | null { + const htmlMatch = source.match(LINK_ICON_HTML_RE); + if (htmlMatch?.[1]) return htmlMatch[1]; + const objMatch = source.match(LINK_ICON_OBJ_RE); + if (objMatch?.[1]) return objMatch[1]; + return null; +} + +function resolveIconHref(projectCwd: string, href: string): string[] { + const clean = href.replace(/^\//, ""); + return [path.join(projectCwd, "public", clean), path.join(projectCwd, clean)]; +} + +function isPathWithinProject(projectCwd: string, candidatePath: string): boolean { + const relative = path.relative(path.resolve(projectCwd), path.resolve(candidatePath)); + return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); +} + +function serveFaviconFile(filePath: string, res: http.ServerResponse): void { + const ext = path.extname(filePath).toLowerCase(); + const contentType = FAVICON_MIME_TYPES[ext] ?? "application/octet-stream"; + fs.readFile(filePath, (readErr, data) => { + if (readErr) { + res.writeHead(500, { "Content-Type": "text/plain" }); + res.end("Read error"); + return; + } + res.writeHead(200, { + "Content-Type": contentType, + "Cache-Control": "public, max-age=3600", + }); + res.end(data); + }); +} + +function serveFallbackFavicon(res: http.ServerResponse): void { + res.writeHead(200, { + "Content-Type": "image/svg+xml", + "Cache-Control": "public, max-age=3600", + }); + res.end(FALLBACK_FAVICON_SVG); +} + +function serveNoContent(res: http.ServerResponse): void { + res.writeHead(204, { + "Cache-Control": "public, max-age=3600", + }); + res.end(); +} + +export function tryHandleProjectFaviconRequest(url: URL, res: http.ServerResponse): boolean { + if (url.pathname !== "/api/project-favicon") { + return false; + } + + const projectCwd = url.searchParams.get("cwd"); + if (!projectCwd) { + res.writeHead(400, { "Content-Type": "text/plain" }); + res.end("Missing cwd parameter"); + return true; + } + + const shouldServeFallback = url.searchParams.get("fallback") !== "none"; + + const tryResolvedPaths = (paths: string[], index: number, onExhausted: () => void): void => { + if (index >= paths.length) { + onExhausted(); + return; + } + const candidate = paths[index]!; + if (!isPathWithinProject(projectCwd, candidate)) { + tryResolvedPaths(paths, index + 1, onExhausted); + return; + } + fs.stat(candidate, (err, stats) => { + if (err || !stats?.isFile()) { + tryResolvedPaths(paths, index + 1, onExhausted); + return; + } + serveFaviconFile(candidate, res); + }); + }; + + const trySourceFiles = (index: number): void => { + if (index >= ICON_SOURCE_FILES.length) { + if (shouldServeFallback) { + serveFallbackFavicon(res); + return; + } + serveNoContent(res); + return; + } + const sourceFile = path.join(projectCwd, ICON_SOURCE_FILES[index]!); + fs.readFile(sourceFile, "utf8", (err, content) => { + if (err) { + trySourceFiles(index + 1); + return; + } + const href = extractIconHref(content); + if (!href) { + trySourceFiles(index + 1); + return; + } + const candidates = resolveIconHref(projectCwd, href); + tryResolvedPaths(candidates, 0, () => trySourceFiles(index + 1)); + }); + }; + + const tryCandidates = (index: number): void => { + if (index >= FAVICON_CANDIDATES.length) { + trySourceFiles(0); + return; + } + const candidate = path.join(projectCwd, FAVICON_CANDIDATES[index]!); + if (!isPathWithinProject(projectCwd, candidate)) { + tryCandidates(index + 1); + return; + } + fs.stat(candidate, (err, stats) => { + if (err || !stats?.isFile()) { + tryCandidates(index + 1); + return; + } + serveFaviconFile(candidate, res); + }); + }; + + tryCandidates(0); + return true; +} diff --git a/apps/server/src/provider/Layers/ProviderHealth.test.ts b/apps/server/src/provider/Layers/ProviderHealth.test.ts new file mode 100644 index 000000000000..4cd0de8d08a2 --- /dev/null +++ b/apps/server/src/provider/Layers/ProviderHealth.test.ts @@ -0,0 +1,641 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { describe, it, assert } from "@effect/vitest"; +import { Effect, FileSystem, Layer, Path, Sink, Stream } from "effect"; +import * as PlatformError from "effect/PlatformError"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import { + checkClaudeProviderStatus, + checkCodexProviderStatus, + hasCustomModelProvider, + parseAuthStatusFromOutput, + parseClaudeAuthStatusFromOutput, + readCodexConfigModelProvider, +} from "./ProviderHealth"; + +// ── Test helpers ──────────────────────────────────────────────────── + +const encoder = new TextEncoder(); + +function mockHandle(result: { stdout: string; stderr: string; code: number }) { + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(result.code)), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + stdin: Sink.drain, + stdout: Stream.make(encoder.encode(result.stdout)), + stderr: Stream.make(encoder.encode(result.stderr)), + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + unref: Effect.succeed({ [Symbol()]: true }) as any, + }); +} + +function mockSpawnerLayer( + handler: (args: ReadonlyArray) => { stdout: string; stderr: string; code: number }, +) { + return Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make((command) => { + const cmd = command as unknown as { args: ReadonlyArray }; + return Effect.succeed(mockHandle(handler(cmd.args))); + }), + ); +} + +function failingSpawnerLayer(description: string) { + return Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.fail( + PlatformError.systemError({ + _tag: "NotFound", + module: "ChildProcess", + method: "spawn", + description, + }), + ), + ), + ); +} + +/** + * Create a temporary CODEX_HOME scoped to the current Effect test. + * Cleanup is registered in the test scope rather than via Vitest hooks. + */ +function withTempCodexHome(configContent?: string) { + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tmpDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-test-codex-" }); + + yield* Effect.acquireRelease( + Effect.sync(() => { + const originalCodexHome = process.env.CODEX_HOME; + process.env.CODEX_HOME = tmpDir; + return originalCodexHome; + }), + (originalCodexHome) => + Effect.sync(() => { + if (originalCodexHome !== undefined) { + process.env.CODEX_HOME = originalCodexHome; + } else { + delete process.env.CODEX_HOME; + } + }), + ); + + if (configContent !== undefined) { + yield* fileSystem.writeFileString(path.join(tmpDir, "config.toml"), configContent); + } + + return { tmpDir } as const; + }); +} + +it.layer(NodeServices.layer)("ProviderHealth", (it) => { + // ── checkCodexProviderStatus tests ──────────────────────────────── + // + // These tests control CODEX_HOME to ensure the custom-provider detection + // in hasCustomModelProvider() does not interfere with the auth-probe + // path being tested. + + describe("checkCodexProviderStatus", () => { + it.effect("returns ready when codex is installed and authenticated", () => + Effect.gen(function* () { + // Point CODEX_HOME at an empty tmp dir (no config.toml) so the + // default code path (OpenAI provider, auth probe runs) is exercised. + yield* withTempCodexHome(); + const status = yield* checkCodexProviderStatus; + assert.strictEqual(status.provider, "codex"); + assert.strictEqual(status.status, "ready"); + assert.strictEqual(status.available, true); + assert.strictEqual(status.authStatus, "authenticated"); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "codex 1.0.0\n", stderr: "", code: 0 }; + if (joined === "login status") return { stdout: "Logged in\n", stderr: "", code: 0 }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + + it.effect("returns unavailable when codex is missing", () => + Effect.gen(function* () { + yield* withTempCodexHome(); + const status = yield* checkCodexProviderStatus; + assert.strictEqual(status.provider, "codex"); + assert.strictEqual(status.status, "error"); + assert.strictEqual(status.available, false); + assert.strictEqual(status.authStatus, "unknown"); + assert.strictEqual(status.message, "Codex CLI (`codex`) is not installed or not on PATH."); + }).pipe(Effect.provide(failingSpawnerLayer("spawn codex ENOENT"))), + ); + + it.effect("returns unavailable when codex is below the minimum supported version", () => + Effect.gen(function* () { + yield* withTempCodexHome(); + const status = yield* checkCodexProviderStatus; + assert.strictEqual(status.provider, "codex"); + assert.strictEqual(status.status, "error"); + assert.strictEqual(status.available, false); + assert.strictEqual(status.authStatus, "unknown"); + assert.strictEqual( + status.message, + "Codex CLI v0.36.0 is too old for T3 Code. Upgrade to v0.37.0 or newer and restart T3 Code.", + ); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "codex 0.36.0\n", stderr: "", code: 0 }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + + it.effect("returns unauthenticated when auth probe reports login required", () => + Effect.gen(function* () { + yield* withTempCodexHome(); + const status = yield* checkCodexProviderStatus; + assert.strictEqual(status.provider, "codex"); + assert.strictEqual(status.status, "error"); + assert.strictEqual(status.available, true); + assert.strictEqual(status.authStatus, "unauthenticated"); + assert.strictEqual( + status.message, + "Codex CLI is not authenticated. Run `codex login` and try again.", + ); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "codex 1.0.0\n", stderr: "", code: 0 }; + if (joined === "login status") { + return { stdout: "", stderr: "Not logged in. Run codex login.", code: 1 }; + } + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + + it.effect("returns unauthenticated when login status output includes 'not logged in'", () => + Effect.gen(function* () { + yield* withTempCodexHome(); + const status = yield* checkCodexProviderStatus; + assert.strictEqual(status.provider, "codex"); + assert.strictEqual(status.status, "error"); + assert.strictEqual(status.available, true); + assert.strictEqual(status.authStatus, "unauthenticated"); + assert.strictEqual( + status.message, + "Codex CLI is not authenticated. Run `codex login` and try again.", + ); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "codex 1.0.0\n", stderr: "", code: 0 }; + if (joined === "login status") + return { stdout: "Not logged in\n", stderr: "", code: 1 }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + + it.effect("returns warning when login status command is unsupported", () => + Effect.gen(function* () { + yield* withTempCodexHome(); + const status = yield* checkCodexProviderStatus; + assert.strictEqual(status.provider, "codex"); + assert.strictEqual(status.status, "warning"); + assert.strictEqual(status.available, true); + assert.strictEqual(status.authStatus, "unknown"); + assert.strictEqual( + status.message, + "Codex CLI authentication status command is unavailable in this Codex version.", + ); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "codex 1.0.0\n", stderr: "", code: 0 }; + if (joined === "login status") { + return { stdout: "", stderr: "error: unknown command 'login'", code: 2 }; + } + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + }); + + // ── Custom model provider: checkCodexProviderStatus integration ─── + + describe("checkCodexProviderStatus with custom model provider", () => { + it.effect("skips auth probe and returns ready when a custom model provider is configured", () => + Effect.gen(function* () { + yield* withTempCodexHome( + [ + 'model_provider = "portkey"', + "", + "[model_providers.portkey]", + 'base_url = "https://api.portkey.ai/v1"', + 'env_key = "PORTKEY_API_KEY"', + ].join("\n"), + ); + const status = yield* checkCodexProviderStatus; + assert.strictEqual(status.provider, "codex"); + assert.strictEqual(status.status, "ready"); + assert.strictEqual(status.available, true); + assert.strictEqual(status.authStatus, "unknown"); + assert.strictEqual( + status.message, + "Using a custom Codex model provider; OpenAI login check skipped.", + ); + }).pipe( + Effect.provide( + // The spawner only handles --version; if the test attempts + // "login status" the throw proves the auth probe was NOT skipped. + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "codex 1.0.0\n", stderr: "", code: 0 }; + throw new Error(`Auth probe should have been skipped but got args: ${joined}`); + }), + ), + ), + ); + + it.effect("still reports error when codex CLI is missing even with custom provider", () => + Effect.gen(function* () { + yield* withTempCodexHome( + [ + 'model_provider = "portkey"', + "", + "[model_providers.portkey]", + 'base_url = "https://api.portkey.ai/v1"', + 'env_key = "PORTKEY_API_KEY"', + ].join("\n"), + ); + const status = yield* checkCodexProviderStatus; + assert.strictEqual(status.status, "error"); + assert.strictEqual(status.available, false); + }).pipe(Effect.provide(failingSpawnerLayer("spawn codex ENOENT"))), + ); + }); + + describe("checkCodexProviderStatus with openai model provider", () => { + it.effect("still runs auth probe when model_provider is openai", () => + Effect.gen(function* () { + yield* withTempCodexHome('model_provider = "openai"\n'); + const status = yield* checkCodexProviderStatus; + // The auth probe runs and sees "not logged in" → error + assert.strictEqual(status.status, "error"); + assert.strictEqual(status.authStatus, "unauthenticated"); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "codex 1.0.0\n", stderr: "", code: 0 }; + if (joined === "login status") + return { stdout: "Not logged in\n", stderr: "", code: 1 }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + }); + + // ── parseAuthStatusFromOutput pure tests ────────────────────────── + + describe("parseAuthStatusFromOutput", () => { + it("exit code 0 with no auth markers is ready", () => { + const parsed = parseAuthStatusFromOutput({ stdout: "OK\n", stderr: "", code: 0 }); + assert.strictEqual(parsed.status, "ready"); + assert.strictEqual(parsed.authStatus, "authenticated"); + }); + + it("JSON with authenticated=false is unauthenticated", () => { + const parsed = parseAuthStatusFromOutput({ + stdout: '[{"authenticated":false}]\n', + stderr: "", + code: 0, + }); + assert.strictEqual(parsed.status, "error"); + assert.strictEqual(parsed.authStatus, "unauthenticated"); + }); + + it("JSON without auth marker is warning", () => { + const parsed = parseAuthStatusFromOutput({ + stdout: '[{"ok":true}]\n', + stderr: "", + code: 0, + }); + assert.strictEqual(parsed.status, "warning"); + assert.strictEqual(parsed.authStatus, "unknown"); + }); + }); + + // ── readCodexConfigModelProvider tests ───────────────────────────── + + describe("readCodexConfigModelProvider", () => { + it.effect("returns undefined when config file does not exist", () => + Effect.gen(function* () { + yield* withTempCodexHome(); + assert.strictEqual(yield* readCodexConfigModelProvider, undefined); + }), + ); + + it.effect("returns undefined when config has no model_provider key", () => + Effect.gen(function* () { + yield* withTempCodexHome('model = "gpt-5-codex"\n'); + assert.strictEqual(yield* readCodexConfigModelProvider, undefined); + }), + ); + + it.effect("returns the provider when model_provider is set at top level", () => + Effect.gen(function* () { + yield* withTempCodexHome('model = "gpt-5-codex"\nmodel_provider = "portkey"\n'); + assert.strictEqual(yield* readCodexConfigModelProvider, "portkey"); + }), + ); + + it.effect("returns openai when model_provider is openai", () => + Effect.gen(function* () { + yield* withTempCodexHome('model_provider = "openai"\n'); + assert.strictEqual(yield* readCodexConfigModelProvider, "openai"); + }), + ); + + it.effect("ignores model_provider inside section headers", () => + Effect.gen(function* () { + yield* withTempCodexHome( + [ + 'model = "gpt-5-codex"', + "", + "[model_providers.portkey]", + 'base_url = "https://api.portkey.ai/v1"', + 'model_provider = "should-be-ignored"', + "", + ].join("\n"), + ); + assert.strictEqual(yield* readCodexConfigModelProvider, undefined); + }), + ); + + it.effect("handles comments and whitespace", () => + Effect.gen(function* () { + yield* withTempCodexHome( + [ + "# This is a comment", + "", + ' model_provider = "azure" ', + "", + "[profiles.deep-review]", + 'model = "gpt-5-pro"', + ].join("\n"), + ); + assert.strictEqual(yield* readCodexConfigModelProvider, "azure"); + }), + ); + + it.effect("handles single-quoted values in TOML", () => + Effect.gen(function* () { + yield* withTempCodexHome("model_provider = 'mistral'\n"); + assert.strictEqual(yield* readCodexConfigModelProvider, "mistral"); + }), + ); + }); + + // ── hasCustomModelProvider tests ─────────────────────────────────── + + describe("hasCustomModelProvider", () => { + it.effect("returns false when no config file exists", () => + Effect.gen(function* () { + yield* withTempCodexHome(); + assert.strictEqual(yield* hasCustomModelProvider, false); + }), + ); + + it.effect("returns false when model_provider is not set", () => + Effect.gen(function* () { + yield* withTempCodexHome('model = "gpt-5-codex"\n'); + assert.strictEqual(yield* hasCustomModelProvider, false); + }), + ); + + it.effect("returns false when model_provider is openai", () => + Effect.gen(function* () { + yield* withTempCodexHome('model_provider = "openai"\n'); + assert.strictEqual(yield* hasCustomModelProvider, false); + }), + ); + + it.effect("returns true when model_provider is portkey", () => + Effect.gen(function* () { + yield* withTempCodexHome('model_provider = "portkey"\n'); + assert.strictEqual(yield* hasCustomModelProvider, true); + }), + ); + + it.effect("returns true when model_provider is azure", () => + Effect.gen(function* () { + yield* withTempCodexHome('model_provider = "azure"\n'); + assert.strictEqual(yield* hasCustomModelProvider, true); + }), + ); + + it.effect("returns true when model_provider is ollama", () => + Effect.gen(function* () { + yield* withTempCodexHome('model_provider = "ollama"\n'); + assert.strictEqual(yield* hasCustomModelProvider, true); + }), + ); + + it.effect("returns true when model_provider is a custom proxy", () => + Effect.gen(function* () { + yield* withTempCodexHome('model_provider = "my-company-proxy"\n'); + assert.strictEqual(yield* hasCustomModelProvider, true); + }), + ); + }); + + // ── checkClaudeProviderStatus tests ────────────────────────── + + describe("checkClaudeProviderStatus", () => { + it.effect("returns ready when claude is installed and authenticated", () => + Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus; + assert.strictEqual(status.provider, "claudeAgent"); + assert.strictEqual(status.status, "ready"); + assert.strictEqual(status.available, true); + assert.strictEqual(status.authStatus, "authenticated"); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; + if (joined === "auth status") + return { + stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', + stderr: "", + code: 0, + }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + + it.effect("returns unavailable when claude is missing", () => + Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus; + assert.strictEqual(status.provider, "claudeAgent"); + assert.strictEqual(status.status, "error"); + assert.strictEqual(status.available, false); + assert.strictEqual(status.authStatus, "unknown"); + assert.strictEqual( + status.message, + "Claude Agent CLI (`claude`) is not installed or not on PATH.", + ); + }).pipe(Effect.provide(failingSpawnerLayer("spawn claude ENOENT"))), + ); + + it.effect("returns error when version check fails with non-zero exit code", () => + Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus; + assert.strictEqual(status.provider, "claudeAgent"); + assert.strictEqual(status.status, "error"); + assert.strictEqual(status.available, false); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") + return { stdout: "", stderr: "Something went wrong", code: 1 }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + + it.effect("returns unauthenticated when auth status reports not logged in", () => + Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus; + assert.strictEqual(status.provider, "claudeAgent"); + assert.strictEqual(status.status, "error"); + assert.strictEqual(status.available, true); + assert.strictEqual(status.authStatus, "unauthenticated"); + assert.strictEqual( + status.message, + "Claude is not authenticated. Run `claude auth login` and try again.", + ); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; + if (joined === "auth status") + return { + stdout: '{"loggedIn":false}\n', + stderr: "", + code: 1, + }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + + it.effect("returns unauthenticated when output includes 'not logged in'", () => + Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus; + assert.strictEqual(status.provider, "claudeAgent"); + assert.strictEqual(status.status, "error"); + assert.strictEqual(status.available, true); + assert.strictEqual(status.authStatus, "unauthenticated"); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; + if (joined === "auth status") return { stdout: "Not logged in\n", stderr: "", code: 1 }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + + it.effect("returns warning when auth status command is unsupported", () => + Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus; + assert.strictEqual(status.provider, "claudeAgent"); + assert.strictEqual(status.status, "warning"); + assert.strictEqual(status.available, true); + assert.strictEqual(status.authStatus, "unknown"); + assert.strictEqual( + status.message, + "Claude Agent authentication status command is unavailable in this version of Claude.", + ); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; + if (joined === "auth status") + return { stdout: "", stderr: "error: unknown command 'auth'", code: 2 }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + }); + + // ── parseClaudeAuthStatusFromOutput pure tests ──────────────────── + + describe("parseClaudeAuthStatusFromOutput", () => { + it("exit code 0 with no auth markers is ready", () => { + const parsed = parseClaudeAuthStatusFromOutput({ stdout: "OK\n", stderr: "", code: 0 }); + assert.strictEqual(parsed.status, "ready"); + assert.strictEqual(parsed.authStatus, "authenticated"); + }); + + it("JSON with loggedIn=true is authenticated", () => { + const parsed = parseClaudeAuthStatusFromOutput({ + stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', + stderr: "", + code: 0, + }); + assert.strictEqual(parsed.status, "ready"); + assert.strictEqual(parsed.authStatus, "authenticated"); + }); + + it("JSON with loggedIn=false is unauthenticated", () => { + const parsed = parseClaudeAuthStatusFromOutput({ + stdout: '{"loggedIn":false}\n', + stderr: "", + code: 0, + }); + assert.strictEqual(parsed.status, "error"); + assert.strictEqual(parsed.authStatus, "unauthenticated"); + }); + + it("JSON without auth marker is warning", () => { + const parsed = parseClaudeAuthStatusFromOutput({ + stdout: '{"ok":true}\n', + stderr: "", + code: 0, + }); + assert.strictEqual(parsed.status, "warning"); + assert.strictEqual(parsed.authStatus, "unknown"); + }); + }); +}); diff --git a/apps/server/src/provider/Layers/ProviderHealth.ts b/apps/server/src/provider/Layers/ProviderHealth.ts new file mode 100644 index 000000000000..6ab7cffefaff --- /dev/null +++ b/apps/server/src/provider/Layers/ProviderHealth.ts @@ -0,0 +1,601 @@ +/** + * ProviderHealthLive - Startup-time provider health checks. + * + * Performs one-time provider readiness probes when the server starts and + * keeps the resulting snapshot in memory for `server.getConfig`. + * + * Uses effect's ChildProcessSpawner to run CLI probes natively. + * + * @module ProviderHealthLive + */ +import * as OS from "node:os"; +import type { ServerProviderAuthStatus } from "@t3tools/contracts"; + +import type { ServerProviderStatus, ServerProviderStatusState } from "../Services/ProviderHealth"; +import { Array, Effect, Fiber, FileSystem, Layer, Option, Path, Result, Stream } from "effect"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; + +import { + formatCodexCliUpgradeMessage, + isCodexCliVersionSupported, + parseCodexCliVersion, +} from "../codexCliVersion"; +import { ProviderHealth, type ProviderHealthShape } from "../Services/ProviderHealth"; + +const DEFAULT_TIMEOUT_MS = 4_000; +const CODEX_PROVIDER = "codex" as const; +const CLAUDE_AGENT_PROVIDER = "claudeAgent" as const; + +// ── Pure helpers ──────────────────────────────────────────────────── + +export interface CommandResult { + readonly stdout: string; + readonly stderr: string; + readonly code: number; +} + +function nonEmptyTrimmed(value: string | undefined): string | undefined { + if (!value) return undefined; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +function isCommandMissingCause(error: unknown): boolean { + if (!(error instanceof Error)) return false; + const lower = error.message.toLowerCase(); + return lower.includes("enoent") || lower.includes("notfound"); +} + +function detailFromResult( + result: CommandResult & { readonly timedOut?: boolean }, +): string | undefined { + if (result.timedOut) return "Timed out while running command."; + const stderr = nonEmptyTrimmed(result.stderr); + if (stderr) return stderr; + const stdout = nonEmptyTrimmed(result.stdout); + if (stdout) return stdout; + if (result.code !== 0) { + return `Command exited with code ${result.code}.`; + } + return undefined; +} + +function extractAuthBoolean(value: unknown): boolean | undefined { + if (Array.isArray(value)) { + for (const entry of value) { + const nested = extractAuthBoolean(entry); + if (nested !== undefined) return nested; + } + return undefined; + } + + if (!value || typeof value !== "object") return undefined; + + const record = value as Record; + for (const key of ["authenticated", "isAuthenticated", "loggedIn", "isLoggedIn"] as const) { + if (typeof record[key] === "boolean") return record[key]; + } + for (const key of ["auth", "status", "session", "account"] as const) { + const nested = extractAuthBoolean(record[key]); + if (nested !== undefined) return nested; + } + return undefined; +} + +export function parseAuthStatusFromOutput(result: CommandResult): { + readonly status: ServerProviderStatusState; + readonly authStatus: ServerProviderAuthStatus; + readonly message?: string; +} { + const lowerOutput = `${result.stdout}\n${result.stderr}`.toLowerCase(); + + if ( + lowerOutput.includes("unknown command") || + lowerOutput.includes("unrecognized command") || + lowerOutput.includes("unexpected argument") + ) { + return { + status: "warning", + authStatus: "unknown", + message: "Codex CLI authentication status command is unavailable in this Codex version.", + }; + } + + if ( + lowerOutput.includes("not logged in") || + lowerOutput.includes("login required") || + lowerOutput.includes("authentication required") || + lowerOutput.includes("run `codex login`") || + lowerOutput.includes("run codex login") + ) { + return { + status: "error", + authStatus: "unauthenticated", + message: "Codex CLI is not authenticated. Run `codex login` and try again.", + }; + } + + const parsedAuth = (() => { + const trimmed = result.stdout.trim(); + if (!trimmed || (!trimmed.startsWith("{") && !trimmed.startsWith("["))) { + return { attemptedJsonParse: false as const, auth: undefined as boolean | undefined }; + } + try { + return { + attemptedJsonParse: true as const, + auth: extractAuthBoolean(JSON.parse(trimmed)), + }; + } catch { + return { attemptedJsonParse: false as const, auth: undefined as boolean | undefined }; + } + })(); + + if (parsedAuth.auth === true) { + return { status: "ready", authStatus: "authenticated" }; + } + if (parsedAuth.auth === false) { + return { + status: "error", + authStatus: "unauthenticated", + message: "Codex CLI is not authenticated. Run `codex login` and try again.", + }; + } + if (parsedAuth.attemptedJsonParse) { + return { + status: "warning", + authStatus: "unknown", + message: + "Could not verify Codex authentication status from JSON output (missing auth marker).", + }; + } + if (result.code === 0) { + return { status: "ready", authStatus: "authenticated" }; + } + + const detail = detailFromResult(result); + return { + status: "warning", + authStatus: "unknown", + message: detail + ? `Could not verify Codex authentication status. ${detail}` + : "Could not verify Codex authentication status.", + }; +} + +// ── Codex CLI config detection ────────────────────────────────────── + +/** + * Providers that use OpenAI-native authentication via `codex login`. + * When the configured `model_provider` is one of these, the `codex login + * status` probe still runs. For any other provider value the auth probe + * is skipped because authentication is handled externally (e.g. via + * environment variables like `PORTKEY_API_KEY` or `AZURE_API_KEY`). + */ +const OPENAI_AUTH_PROVIDERS = new Set(["openai"]); + +/** + * Read the `model_provider` value from the Codex CLI config file. + * + * Looks for the file at `$CODEX_HOME/config.toml` (falls back to + * `~/.codex/config.toml`). Uses a simple line-by-line scan rather than + * a full TOML parser to avoid adding a dependency for a single key. + * + * Returns `undefined` when the file does not exist or does not set + * `model_provider`. + */ +export const readCodexConfigModelProvider = Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const codexHome = process.env.CODEX_HOME || path.join(OS.homedir(), ".codex"); + const configPath = path.join(codexHome, "config.toml"); + + const content = yield* fileSystem + .readFileString(configPath) + .pipe(Effect.orElseSucceed(() => undefined)); + if (content === undefined) { + return undefined; + } + + // We need to find `model_provider = "..."` at the top level of the + // TOML file (i.e. before any `[section]` header). Lines inside + // `[profiles.*]`, `[model_providers.*]`, etc. are ignored. + let inTopLevel = true; + for (const line of content.split("\n")) { + const trimmed = line.trim(); + // Skip comments and empty lines. + if (!trimmed || trimmed.startsWith("#")) continue; + // Detect section headers — once we leave the top level, stop. + if (trimmed.startsWith("[")) { + inTopLevel = false; + continue; + } + if (!inTopLevel) continue; + + const match = trimmed.match(/^model_provider\s*=\s*["']([^"']+)["']/); + if (match) return match[1]; + } + return undefined; +}); + +/** + * Returns `true` when the Codex CLI is configured with a custom + * (non-OpenAI) model provider, meaning `codex login` auth is not + * required because authentication is handled through provider-specific + * environment variables. + */ +export const hasCustomModelProvider = Effect.map( + readCodexConfigModelProvider, + (provider) => provider !== undefined && !OPENAI_AUTH_PROVIDERS.has(provider), +); + +// ── Effect-native command execution ───────────────────────────────── + +const collectStreamAsString = (stream: Stream.Stream): Effect.Effect => + Stream.runFold( + stream, + () => "", + (acc, chunk) => acc + new TextDecoder().decode(chunk), + ); + +const runCodexCommand = (args: ReadonlyArray) => + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const command = ChildProcess.make("codex", [...args], { + shell: process.platform === "win32", + }); + + const child = yield* spawner.spawn(command); + + const [stdout, stderr, exitCode] = yield* Effect.all( + [ + collectStreamAsString(child.stdout), + collectStreamAsString(child.stderr), + child.exitCode.pipe(Effect.map(Number)), + ], + { concurrency: "unbounded" }, + ); + + return { stdout, stderr, code: exitCode } satisfies CommandResult; + }).pipe(Effect.scoped); + +const runClaudeCommand = (args: ReadonlyArray) => + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const command = ChildProcess.make("claude", [...args], { + shell: process.platform === "win32", + }); + + const child = yield* spawner.spawn(command); + + const [stdout, stderr, exitCode] = yield* Effect.all( + [ + collectStreamAsString(child.stdout), + collectStreamAsString(child.stderr), + child.exitCode.pipe(Effect.map(Number)), + ], + { concurrency: "unbounded" }, + ); + + return { stdout, stderr, code: exitCode } satisfies CommandResult; + }).pipe(Effect.scoped); + +// ── Health check ──────────────────────────────────────────────────── + +export const checkCodexProviderStatus: Effect.Effect< + ServerProviderStatus, + never, + ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | Path.Path +> = Effect.gen(function* () { + const checkedAt = new Date().toISOString(); + + // Probe 1: `codex --version` — is the CLI reachable? + const versionProbe = yield* runCodexCommand(["--version"]).pipe( + Effect.timeoutOption(DEFAULT_TIMEOUT_MS), + Effect.result, + ); + + if (Result.isFailure(versionProbe)) { + const error = versionProbe.failure; + return { + provider: CODEX_PROVIDER, + status: "error" as const, + available: false, + authStatus: "unknown" as const, + checkedAt, + message: isCommandMissingCause(error) + ? "Codex CLI (`codex`) is not installed or not on PATH." + : `Failed to execute Codex CLI health check: ${error instanceof Error ? error.message : String(error)}.`, + }; + } + + if (Option.isNone(versionProbe.success)) { + return { + provider: CODEX_PROVIDER, + status: "error" as const, + available: false, + authStatus: "unknown" as const, + checkedAt, + message: "Codex CLI is installed but failed to run. Timed out while running command.", + }; + } + + const version = versionProbe.success.value; + if (version.code !== 0) { + const detail = detailFromResult(version); + return { + provider: CODEX_PROVIDER, + status: "error" as const, + available: false, + authStatus: "unknown" as const, + checkedAt, + message: detail + ? `Codex CLI is installed but failed to run. ${detail}` + : "Codex CLI is installed but failed to run.", + }; + } + + const parsedVersion = parseCodexCliVersion(`${version.stdout}\n${version.stderr}`); + if (parsedVersion && !isCodexCliVersionSupported(parsedVersion)) { + return { + provider: CODEX_PROVIDER, + status: "error" as const, + available: false, + authStatus: "unknown" as const, + checkedAt, + message: formatCodexCliUpgradeMessage(parsedVersion), + }; + } + + // Probe 2: `codex login status` — is the user authenticated? + // + // Custom model providers (e.g. Portkey, Azure OpenAI proxy) handle + // authentication through their own environment variables, so `codex + // login status` will report "not logged in" even when the CLI works + // fine. Skip the auth probe entirely for non-OpenAI providers. + if (yield* hasCustomModelProvider) { + return { + provider: CODEX_PROVIDER, + status: "ready" as const, + available: true, + authStatus: "unknown" as const, + checkedAt, + message: "Using a custom Codex model provider; OpenAI login check skipped.", + } satisfies ServerProviderStatus; + } + + const authProbe = yield* runCodexCommand(["login", "status"]).pipe( + Effect.timeoutOption(DEFAULT_TIMEOUT_MS), + Effect.result, + ); + + if (Result.isFailure(authProbe)) { + const error = authProbe.failure; + return { + provider: CODEX_PROVIDER, + status: "warning" as const, + available: true, + authStatus: "unknown" as const, + checkedAt, + message: + error instanceof Error + ? `Could not verify Codex authentication status: ${error.message}.` + : "Could not verify Codex authentication status.", + }; + } + + if (Option.isNone(authProbe.success)) { + return { + provider: CODEX_PROVIDER, + status: "warning" as const, + available: true, + authStatus: "unknown" as const, + checkedAt, + message: "Could not verify Codex authentication status. Timed out while running command.", + }; + } + + const parsed = parseAuthStatusFromOutput(authProbe.success.value); + return { + provider: CODEX_PROVIDER, + status: parsed.status, + available: true, + authStatus: parsed.authStatus, + checkedAt, + ...(parsed.message ? { message: parsed.message } : {}), + } satisfies ServerProviderStatus; +}); + +// ── Claude Agent health check ─────────────────────────────────────── + +export function parseClaudeAuthStatusFromOutput(result: CommandResult): { + readonly status: ServerProviderStatusState; + readonly authStatus: ServerProviderAuthStatus; + readonly message?: string; +} { + const lowerOutput = `${result.stdout}\n${result.stderr}`.toLowerCase(); + + if ( + lowerOutput.includes("unknown command") || + lowerOutput.includes("unrecognized command") || + lowerOutput.includes("unexpected argument") + ) { + return { + status: "warning", + authStatus: "unknown", + message: + "Claude Agent authentication status command is unavailable in this version of Claude.", + }; + } + + if ( + lowerOutput.includes("not logged in") || + lowerOutput.includes("login required") || + lowerOutput.includes("authentication required") || + lowerOutput.includes("run `claude login`") || + lowerOutput.includes("run claude login") + ) { + return { + status: "error", + authStatus: "unauthenticated", + message: "Claude is not authenticated. Run `claude auth login` and try again.", + }; + } + + // `claude auth status` returns JSON with a `loggedIn` boolean. + const parsedAuth = (() => { + const trimmed = result.stdout.trim(); + if (!trimmed || (!trimmed.startsWith("{") && !trimmed.startsWith("["))) { + return { attemptedJsonParse: false as const, auth: undefined as boolean | undefined }; + } + try { + return { + attemptedJsonParse: true as const, + auth: extractAuthBoolean(JSON.parse(trimmed)), + }; + } catch { + return { attemptedJsonParse: false as const, auth: undefined as boolean | undefined }; + } + })(); + + if (parsedAuth.auth === true) { + return { status: "ready", authStatus: "authenticated" }; + } + if (parsedAuth.auth === false) { + return { + status: "error", + authStatus: "unauthenticated", + message: "Claude is not authenticated. Run `claude auth login` and try again.", + }; + } + if (parsedAuth.attemptedJsonParse) { + return { + status: "warning", + authStatus: "unknown", + message: + "Could not verify Claude authentication status from JSON output (missing auth marker).", + }; + } + if (result.code === 0) { + return { status: "ready", authStatus: "authenticated" }; + } + + const detail = detailFromResult(result); + return { + status: "warning", + authStatus: "unknown", + message: detail + ? `Could not verify Claude authentication status. ${detail}` + : "Could not verify Claude authentication status.", + }; +} + +export const checkClaudeProviderStatus: Effect.Effect< + ServerProviderStatus, + never, + ChildProcessSpawner.ChildProcessSpawner +> = Effect.gen(function* () { + const checkedAt = new Date().toISOString(); + + // Probe 1: `claude --version` — is the CLI reachable? + const versionProbe = yield* runClaudeCommand(["--version"]).pipe( + Effect.timeoutOption(DEFAULT_TIMEOUT_MS), + Effect.result, + ); + + if (Result.isFailure(versionProbe)) { + const error = versionProbe.failure; + return { + provider: CLAUDE_AGENT_PROVIDER, + status: "error" as const, + available: false, + authStatus: "unknown" as const, + checkedAt, + message: isCommandMissingCause(error) + ? "Claude Agent CLI (`claude`) is not installed or not on PATH." + : `Failed to execute Claude Agent CLI health check: ${error instanceof Error ? error.message : String(error)}.`, + }; + } + + if (Option.isNone(versionProbe.success)) { + return { + provider: CLAUDE_AGENT_PROVIDER, + status: "error" as const, + available: false, + authStatus: "unknown" as const, + checkedAt, + message: "Claude Agent CLI is installed but failed to run. Timed out while running command.", + }; + } + + const version = versionProbe.success.value; + if (version.code !== 0) { + const detail = detailFromResult(version); + return { + provider: CLAUDE_AGENT_PROVIDER, + status: "error" as const, + available: false, + authStatus: "unknown" as const, + checkedAt, + message: detail + ? `Claude Agent CLI is installed but failed to run. ${detail}` + : "Claude Agent CLI is installed but failed to run.", + }; + } + + // Probe 2: `claude auth status` — is the user authenticated? + const authProbe = yield* runClaudeCommand(["auth", "status"]).pipe( + Effect.timeoutOption(DEFAULT_TIMEOUT_MS), + Effect.result, + ); + + if (Result.isFailure(authProbe)) { + const error = authProbe.failure; + return { + provider: CLAUDE_AGENT_PROVIDER, + status: "warning" as const, + available: true, + authStatus: "unknown" as const, + checkedAt, + message: + error instanceof Error + ? `Could not verify Claude authentication status: ${error.message}.` + : "Could not verify Claude authentication status.", + }; + } + + if (Option.isNone(authProbe.success)) { + return { + provider: CLAUDE_AGENT_PROVIDER, + status: "warning" as const, + available: true, + authStatus: "unknown" as const, + checkedAt, + message: "Could not verify Claude authentication status. Timed out while running command.", + }; + } + + const parsed = parseClaudeAuthStatusFromOutput(authProbe.success.value); + return { + provider: CLAUDE_AGENT_PROVIDER, + status: parsed.status, + available: true, + authStatus: parsed.authStatus, + checkedAt, + ...(parsed.message ? { message: parsed.message } : {}), + } satisfies ServerProviderStatus; +}); + +// ── Layer ─────────────────────────────────────────────────────────── + +export const ProviderHealthLive = Layer.effect( + ProviderHealth, + Effect.gen(function* () { + const statusesFiber = yield* Effect.all([checkCodexProviderStatus, checkClaudeProviderStatus], { + concurrency: "unbounded", + }).pipe(Effect.forkScoped); + + return { + getStatuses: Fiber.join(statusesFiber), + } satisfies ProviderHealthShape; + }), +); diff --git a/apps/server/src/provider/Services/ProviderHealth.ts b/apps/server/src/provider/Services/ProviderHealth.ts new file mode 100644 index 000000000000..e373527cab2d --- /dev/null +++ b/apps/server/src/provider/Services/ProviderHealth.ts @@ -0,0 +1,39 @@ +/** + * ProviderHealth - Provider readiness snapshot service. + * + * Owns provider health checks (install/auth reachability) and exposes the + * latest results to transport layers. + * + * @module ProviderHealth + */ +import type { ServerProviderAuthStatus } from "@t3tools/contracts"; +import { Context } from "effect"; +import type { Effect } from "effect"; + +/** + * Lightweight status snapshot returned by startup health probes. + * + * Intentionally decoupled from the full `ServerProvider` contract which + * carries model lists, slash-commands, etc. + */ +export type ServerProviderStatusState = "ready" | "warning" | "error"; + +export interface ServerProviderStatus { + readonly provider: string; + readonly status: ServerProviderStatusState; + readonly available: boolean; + readonly authStatus: ServerProviderAuthStatus; + readonly checkedAt: string; + readonly message?: string; +} + +export interface ProviderHealthShape { + /** + * Read the latest provider health statuses. + */ + readonly getStatuses: Effect.Effect>; +} + +export class ProviderHealth extends Context.Service()( + "t3/provider/Services/ProviderHealth", +) {} diff --git a/apps/server/src/terminal/managedTerminalWrappers.ts b/apps/server/src/terminal/managedTerminalWrappers.ts new file mode 100644 index 000000000000..895e482f321f --- /dev/null +++ b/apps/server/src/terminal/managedTerminalWrappers.ts @@ -0,0 +1,500 @@ +// FILE: managedTerminalWrappers.ts +// Purpose: Create Superset-style managed command wrappers so terminal agent identity is canonical +// and survives zsh startup that rewrites PATH. + +import fs from "node:fs"; +import path from "node:path"; + +import { + defaultTerminalTitleForCliKind, + managedTerminalCommandNameForCliKind, + T3CODE_TERMINAL_HOOK_OSC_PREFIX, + T3CODE_TERMINAL_CLI_KIND_ENV_KEY, + type TerminalAgentHookEventType, + type TerminalCliKind, +} from "@t3tools/shared/terminalThreads"; + +export interface ManagedTerminalWrapperState { + binDir: string | null; + codexHomeDir: string | null; + hookScriptPath: string | null; + claudeSettingsPath: string | null; + zshDir: string | null; + targetPathByCliKind: Partial>; +} + +function shellQuote(value: string): string { + return `'${value.replaceAll("'", `'\"'\"'`)}'`; +} + +function envPathKeyFor(env: NodeJS.ProcessEnv): "PATH" | "Path" | "path" { + if ("PATH" in env) return "PATH"; + if ("Path" in env) return "Path"; + return "path"; +} + +function isExecutableFile(filePath: string): boolean { + try { + const stats = fs.statSync(filePath); + if (!stats.isFile()) { + return false; + } + fs.accessSync(filePath, fs.constants.X_OK); + return true; + } catch { + return false; + } +} + +function executableCandidates(commandName: string): string[] { + if (process.platform !== "win32") { + return [commandName]; + } + + const pathExt = process.env.PATHEXT?.split(";").filter(Boolean) ?? [".EXE", ".CMD", ".BAT"]; + const lowerCommandName = commandName.toLowerCase(); + const hasExtension = pathExt.some((extension) => + lowerCommandName.endsWith(extension.toLowerCase()), + ); + return hasExtension ? [commandName] : pathExt.map((extension) => `${commandName}${extension}`); +} + +function resolveExecutableOnPath(commandName: string, env: NodeJS.ProcessEnv): string | null { + const envPathKey = envPathKeyFor(env); + const envPath = env[envPathKey]?.trim(); + if (!envPath) { + return null; + } + + for (const entry of envPath.split(path.delimiter)) { + const directory = entry.trim(); + if (!directory) { + continue; + } + for (const candidateName of executableCandidates(commandName)) { + const candidatePath = path.join(directory, candidateName); + if (isExecutableFile(candidatePath)) { + return candidatePath; + } + } + } + + return null; +} + +function buildHookOscSequence(eventType: TerminalAgentHookEventType): string { + return `\\033]${T3CODE_TERMINAL_HOOK_OSC_PREFIX}${eventType}\\007`; +} + +function buildNotifyHookScript(): string { + return `#!/bin/sh +set -eu +if [ "$#" -gt 0 ]; then + _t3code_hook_input="$1" +else + _t3code_hook_input="$(cat)" +fi + +_t3code_extract_event() { + printf '%s' "$_t3code_hook_input" | sed -n "s/.*\\\"$1\\\"[[:space:]]*:[[:space:]]*\\\"\\([^\\\"]*\\)\\\".*/\\1/p" | head -n 1 +} + +_t3code_event="$(_t3code_extract_event hook_event_name)" +if [ -z "$_t3code_event" ]; then + _t3code_type="$(_t3code_extract_event type)" + case "$_t3code_type" in + task_started|userPromptSubmitted|user_prompt_submit) + _t3code_event="Start" + ;; + task_complete|agent-turn-complete|stop|session_end|sessionEnd) + _t3code_event="Stop" + ;; + exec_approval_request|apply_patch_approval_request|request_user_input) + _t3code_event="PermissionRequest" + ;; + esac +fi + +_t3code_emit_osc() { + _t3code_sequence="$1" + if [ -w /dev/tty ]; then + printf '%b' "$_t3code_sequence" > /dev/tty 2>/dev/null || printf '%b' "$_t3code_sequence" + return + fi + printf '%b' "$_t3code_sequence" +} + +case "$_t3code_event" in + UserPromptSubmit|PostToolUse|PostToolUseFailure|Start) + _t3code_emit_osc '${buildHookOscSequence("Start")}' + ;; + Stop) + _t3code_emit_osc '${buildHookOscSequence("Stop")}' + ;; + PermissionRequest|PreToolUse|Notification) + _t3code_emit_osc '${buildHookOscSequence("PermissionRequest")}' + ;; +esac +`; +} + +function buildClaudeSettingsJson(notifyHookPath: string): string { + const command = notifyHookPath; + return JSON.stringify( + { + hooks: { + UserPromptSubmit: [{ hooks: [{ type: "command", command }] }], + Stop: [{ hooks: [{ type: "command", command }] }], + PostToolUse: [{ matcher: "*", hooks: [{ type: "command", command }] }], + PostToolUseFailure: [{ matcher: "*", hooks: [{ type: "command", command }] }], + PermissionRequest: [{ matcher: "*", hooks: [{ type: "command", command }] }], + Notification: [{ matcher: "*", hooks: [{ type: "command", command }] }], + }, + }, + null, + 2, + ); +} + +function buildCodexHooksJson(notifyHookPath: string): string { + const command = notifyHookPath; + return JSON.stringify( + { + hooks: { + UserPromptSubmit: [{ hooks: [{ type: "command", command }] }], + Stop: [{ hooks: [{ type: "command", command }] }], + }, + }, + null, + 2, + ); +} + +function buildCodexWrapperScript(input: { + codexHomeDir: string; + notifyHookPath: string; + targetPath: string; +}): string { + const { codexHomeDir, notifyHookPath, targetPath } = input; + return [ + `export CODEX_HOME=${shellQuote(codexHomeDir)}`, + `if [ -f ${shellQuote(notifyHookPath)} ]; then`, + " export CODEX_TUI_RECORD_SESSION=1", + ' if [ -z "${CODEX_TUI_SESSION_LOG_PATH:-}" ]; then', + ' _t3code_codex_ts="$(date +%s 2>/dev/null || echo "$$")"', + ' export CODEX_TUI_SESSION_LOG_PATH="${TMPDIR:-/tmp}/t3code-codex-session-$$_${_t3code_codex_ts}.jsonl"', + " fi", + " (", + ' _t3code_log="$CODEX_TUI_SESSION_LOG_PATH"', + ` _t3code_notify=${shellQuote(notifyHookPath)}`, + ' _t3code_last_turn_id=""', + ' _t3code_last_approval_id=""', + ' _t3code_last_exec_call_id=""', + " _t3code_approval_fallback_seq=0", + "", + " _t3code_emit_event() {", + ' _t3code_event="$1"', + ` _t3code_payload=$(printf '{"hook_event_name":"%s"}' "$_t3code_event")`, + ' "$_t3code_notify" "$_t3code_payload" >/dev/null 2>&1 || true', + " }", + "", + " _t3code_i=0", + ' while [ ! -f "$_t3code_log" ] && [ "$_t3code_i" -lt 200 ]; do', + " _t3code_i=$((_t3code_i + 1))", + " sleep 0.05", + " done", + ' if [ ! -f "$_t3code_log" ]; then', + " exit 0", + " fi", + "", + ' tail -n 0 -F "$_t3code_log" 2>/dev/null | while IFS= read -r _t3code_line; do', + ' case "$_t3code_line" in', + ` *'"dir":"to_tui"'*'"kind":"codex_event"'*'"msg":{"type":"task_started"'*)`, + ` _t3code_turn_id=$(printf '%s\n' "$_t3code_line" | awk -F'"turn_id":"' 'NF > 1 { sub(/".*/, "", $2); print $2; exit }')`, + ' [ -n "$_t3code_turn_id" ] || _t3code_turn_id="task_started"', + ' if [ "$_t3code_turn_id" != "$_t3code_last_turn_id" ]; then', + ' _t3code_last_turn_id="$_t3code_turn_id"', + ' _t3code_emit_event "Start"', + " fi", + " ;;", + ` *'"dir":"to_tui"'*'"kind":"codex_event"'*'"msg":{"type":"'*'_approval_request"'*)`, + ` _t3code_approval_id=$(printf '%s\n' "$_t3code_line" | awk -F'"id":"' 'NF > 1 { sub(/".*/, "", $2); print $2; exit }')`, + ` [ -n "$_t3code_approval_id" ] || _t3code_approval_id=$(printf '%s\n' "$_t3code_line" | awk -F'"approval_id":"' 'NF > 1 { sub(/".*/, "", $2); print $2; exit }')`, + ` [ -n "$_t3code_approval_id" ] || _t3code_approval_id=$(printf '%s\n' "$_t3code_line" | awk -F'"call_id":"' 'NF > 1 { sub(/".*/, "", $2); print $2; exit }')`, + ' if [ -z "$_t3code_approval_id" ]; then', + " _t3code_approval_fallback_seq=$((_t3code_approval_fallback_seq + 1))", + ' _t3code_approval_id="approval_request_${_t3code_approval_fallback_seq}"', + " fi", + ' if [ "$_t3code_approval_id" != "$_t3code_last_approval_id" ]; then', + ' _t3code_last_approval_id="$_t3code_approval_id"', + ' _t3code_emit_event "PermissionRequest"', + " fi", + " ;;", + ` *'"dir":"to_tui"'*'"kind":"codex_event"'*'"msg":{"type":"exec_command_begin"'*)`, + ` _t3code_exec_call_id=$(printf '%s\n' "$_t3code_line" | awk -F'"call_id":"' 'NF > 1 { sub(/".*/, "", $2); print $2; exit }')`, + ' if [ -n "$_t3code_exec_call_id" ]; then', + ' if [ "$_t3code_exec_call_id" != "$_t3code_last_exec_call_id" ]; then', + ' _t3code_last_exec_call_id="$_t3code_exec_call_id"', + ' _t3code_emit_event "Start"', + " fi", + " else", + ' _t3code_emit_event "Start"', + " fi", + " ;;", + " esac", + " done", + " ) &", + " T3CODE_CODEX_START_WATCHER_PID=$!", + "fi", + `${shellQuote(targetPath)} --enable codex_hooks -c ${shellQuote(`notify=["bash",${JSON.stringify(notifyHookPath)}]`)} "$@"`, + "_t3code_status=$?", + 'if [ -n "${T3CODE_CODEX_START_WATCHER_PID:-}" ]; then', + ' kill "$T3CODE_CODEX_START_WATCHER_PID" >/dev/null 2>&1 || true', + ' wait "$T3CODE_CODEX_START_WATCHER_PID" 2>/dev/null || true', + "fi", + 'exit "$_t3code_status"', + ].join("\n"); +} + +function buildWrapperScript(input: { + claudeSettingsPath: string; + cliKind: TerminalCliKind; + codexHomeDir: string; + notifyHookPath: string; + targetPath: string; +}): string { + const { claudeSettingsPath, cliKind, codexHomeDir, notifyHookPath, targetPath } = input; + const commandName = managedTerminalCommandNameForCliKind(cliKind); + const title = defaultTerminalTitleForCliKind(cliKind); + const commandBody = + cliKind === "claude" + ? `exec ${shellQuote(targetPath)} --settings ${shellQuote(claudeSettingsPath)} "$@"` + : buildCodexWrapperScript({ codexHomeDir, notifyHookPath, targetPath }); + return [ + "#!/bin/sh", + `# Managed ${commandName} wrapper injected by t3code terminal sessions.`, + `printf '\\033]0;%s\\007' ${shellQuote(title)}`, + `export ${T3CODE_TERMINAL_CLI_KIND_ENV_KEY}=${shellQuote(cliKind)}`, + commandBody, + "", + ].join("\n"); +} + +function writeFileIfChanged(filePath: string, content: string, mode: number): void { + const currentContent = fs.existsSync(filePath) ? fs.readFileSync(filePath, "utf8") : null; + if (currentContent !== content) { + fs.writeFileSync(filePath, content, { mode }); + } + try { + fs.chmodSync(filePath, mode); + } catch { + // Best effort. + } +} + +function buildManagedZshRc(quotedZshDir: string): string { + return `# T3 Code zsh rc wrapper +_t3code_home="\${T3CODE_ORIGINAL_ZDOTDIR:-$HOME}" +export ZDOTDIR="$_t3code_home" +[[ -f "$_t3code_home/.zshrc" ]] && source "$_t3code_home/.zshrc" +export ZDOTDIR=${quotedZshDir} +if [ -n "\${T3CODE_MANAGED_BIN_DIR:-}" ] && [ -d "\${T3CODE_MANAGED_BIN_DIR}" ]; then + case ":$PATH:" in + *:\${T3CODE_MANAGED_BIN_DIR}:*) ;; + *) export PATH="\${T3CODE_MANAGED_BIN_DIR}:$PATH" ;; + esac + unalias claude 2>/dev/null || true + claude() { + if [ -x "\${T3CODE_MANAGED_BIN_DIR}/claude" ] && [ ! -d "\${T3CODE_MANAGED_BIN_DIR}/claude" ]; then + "\${T3CODE_MANAGED_BIN_DIR}/claude" "$@" + else + command claude "$@" + fi + } + unalias codex 2>/dev/null || true + codex() { + if [ -x "\${T3CODE_MANAGED_BIN_DIR}/codex" ] && [ ! -d "\${T3CODE_MANAGED_BIN_DIR}/codex" ]; then + "\${T3CODE_MANAGED_BIN_DIR}/codex" "$@" + else + command codex "$@" + fi + } + typeset -ga precmd_functions 2>/dev/null || true + _t3code_ensure_managed_bin() { + case ":$PATH:" in + *:\${T3CODE_MANAGED_BIN_DIR}:*) ;; + *) PATH="\${T3CODE_MANAGED_BIN_DIR}:$PATH" ;; + esac + } + { + precmd_functions=(\${precmd_functions:#_t3code_ensure_managed_bin} _t3code_ensure_managed_bin) + } 2>/dev/null || true +fi +`; +} + +function ensureManagedZshWrappers(zshDir: string): void { + fs.mkdirSync(zshDir, { recursive: true }); + const quotedZshDir = shellQuote(zshDir); + writeFileIfChanged( + path.join(zshDir, ".zshenv"), + `# T3 Code zsh env wrapper +_t3code_home="\${T3CODE_ORIGINAL_ZDOTDIR:-$HOME}" +export ZDOTDIR="$_t3code_home" +[[ -f "$_t3code_home/.zshenv" ]] && source "$_t3code_home/.zshenv" +export ZDOTDIR=${quotedZshDir} +`, + 0o644, + ); + writeFileIfChanged( + path.join(zshDir, ".zprofile"), + `# T3 Code zsh profile wrapper +_t3code_home="\${T3CODE_ORIGINAL_ZDOTDIR:-$HOME}" +export ZDOTDIR="$_t3code_home" +[[ -f "$_t3code_home/.zprofile" ]] && source "$_t3code_home/.zprofile" +export ZDOTDIR=${quotedZshDir} +`, + 0o644, + ); + writeFileIfChanged(path.join(zshDir, ".zshrc"), buildManagedZshRc(quotedZshDir), 0o644); +} + +export function prepareManagedTerminalWrappers(options: { + baseEnv: NodeJS.ProcessEnv; + rootDir: string; + zshRootDir: string; +}): ManagedTerminalWrapperState { + if (process.platform === "win32") { + return { + binDir: null, + codexHomeDir: null, + hookScriptPath: null, + claudeSettingsPath: null, + zshDir: null, + targetPathByCliKind: {}, + }; + } + + const targetPathByCliKind: Partial> = {}; + for (const cliKind of ["codex", "claude"] as const) { + const commandName = managedTerminalCommandNameForCliKind(cliKind); + const targetPath = resolveExecutableOnPath(commandName, options.baseEnv); + if (!targetPath) { + continue; + } + targetPathByCliKind[cliKind] = targetPath; + } + + if (Object.keys(targetPathByCliKind).length === 0) { + return { + binDir: null, + codexHomeDir: null, + hookScriptPath: null, + claudeSettingsPath: null, + zshDir: null, + targetPathByCliKind, + }; + } + + fs.mkdirSync(options.rootDir, { recursive: true }); + const codexHomeDir = path.join(options.rootDir, "codex-home"); + const hookScriptPath = path.join(options.rootDir, "notify-hook.sh"); + const claudeSettingsPath = path.join(options.rootDir, "claude-settings.json"); + fs.mkdirSync(codexHomeDir, { recursive: true }); + writeFileIfChanged(hookScriptPath, buildNotifyHookScript(), 0o755); + writeFileIfChanged(claudeSettingsPath, buildClaudeSettingsJson(hookScriptPath), 0o644); + writeFileIfChanged( + path.join(codexHomeDir, "hooks.json"), + buildCodexHooksJson(hookScriptPath), + 0o644, + ); + for (const [cliKind, targetPath] of Object.entries(targetPathByCliKind) as Array< + [TerminalCliKind, string] + >) { + const wrapperPath = path.join(options.rootDir, managedTerminalCommandNameForCliKind(cliKind)); + writeFileIfChanged( + wrapperPath, + buildWrapperScript({ + claudeSettingsPath, + cliKind, + codexHomeDir, + notifyHookPath: hookScriptPath, + targetPath, + }), + 0o755, + ); + } + ensureManagedZshWrappers(options.zshRootDir); + + return { + binDir: options.rootDir, + codexHomeDir, + hookScriptPath, + claudeSettingsPath, + zshDir: options.zshRootDir, + targetPathByCliKind, + }; +} + +function applyManagedTerminalWrapperEnvState( + env: NodeJS.ProcessEnv, + wrapperState: { + binDir: string | null; + zshDir: string | null; + }, +): NodeJS.ProcessEnv { + if (!wrapperState.binDir) { + return env; + } + + const envPathKey = envPathKeyFor(env); + const currentPath = env[envPathKey]?.trim() ?? ""; + const currentEntries = currentPath + .split(path.delimiter) + .map((entry) => entry.trim()) + .filter(Boolean); + + if (!currentEntries.includes(wrapperState.binDir)) { + currentEntries.unshift(wrapperState.binDir); + } + + return { + ...env, + T3CODE_MANAGED_BIN_DIR: wrapperState.binDir, + T3CODE_ORIGINAL_ZDOTDIR: env.ZDOTDIR ?? env.HOME ?? "", + ...(wrapperState.zshDir ? { ZDOTDIR: wrapperState.zshDir } : {}), + [envPathKey]: currentEntries.join(path.delimiter), + }; +} + +export function applyManagedTerminalAgentWrapperEnv( + env: NodeJS.ProcessEnv, + wrapperState: { + binDir: string | null; + zshDir: string | null; + }, +): NodeJS.ProcessEnv { + return applyManagedTerminalWrapperEnvState(env, wrapperState); +} + +export function prepareManagedTerminalAgentWrappers(options: { + baseEnv: NodeJS.ProcessEnv; + targetDir: string; + zshDir: string; +}): ManagedTerminalWrapperState { + return prepareManagedTerminalWrappers({ + baseEnv: options.baseEnv, + rootDir: options.targetDir, + zshRootDir: options.zshDir, + }); +} + +export function prependManagedTerminalAgentWrapperPath( + env: NodeJS.ProcessEnv, + managedWrapperState: { + binDir: string | null; + zshDir: string | null; + }, +): NodeJS.ProcessEnv { + return applyManagedTerminalWrapperEnvState(env, managedWrapperState); +} diff --git a/packages/shared/package.json b/packages/shared/package.json index ed65cbeaf3c1..8b44c260c58f 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -56,6 +56,10 @@ "types": "./src/searchRanking.ts", "import": "./src/searchRanking.ts" }, + "./terminalThreads": { + "types": "./src/terminalThreads.ts", + "import": "./src/terminalThreads.ts" + }, "./qrCode": { "types": "./src/qrCode.ts", "import": "./src/qrCode.ts" diff --git a/packages/shared/src/terminalThreads.ts b/packages/shared/src/terminalThreads.ts new file mode 100644 index 000000000000..af17399deb0d --- /dev/null +++ b/packages/shared/src/terminalThreads.ts @@ -0,0 +1,500 @@ +// FILE: terminalThreads.ts +// Purpose: Shared terminal identity helpers for naming, provider attribution, and run state. +// Layer: Shared terminal metadata utilities +// Exports: command parsing plus resolved terminal presentation metadata for web/server consumers. + +export const GENERIC_TERMINAL_THREAD_TITLE = "New terminal"; +export type TerminalCliKind = "codex" | "claude"; +export type TerminalIconKey = "terminal" | "openai" | "claude"; +export type TerminalActivityState = "running" | "attention" | "review"; +export type TerminalVisualState = "idle" | TerminalActivityState; +export type TerminalAgentHookEventType = "Start" | "Stop" | "PermissionRequest"; +export const T3CODE_TERMINAL_CLI_KIND_ENV_KEY = "T3CODE_TERMINAL_CLI_KIND"; +export const T3CODE_TERMINAL_HOOK_OSC_PREFIX = "633;T3CODE_AGENT_EVENT="; +export const MANAGED_TERMINAL_COMMAND_NAME_BY_CLI_KIND: Record = { + codex: "codex", + claude: "claude", +}; + +export interface TerminalCommandIdentity { + cliKind: TerminalCliKind | null; + iconKey: TerminalIconKey; + title: string; +} + +export interface ResolvedTerminalVisualIdentity extends TerminalCommandIdentity { + state: TerminalVisualState; +} + +interface ReconcileTerminalCommandIdentityInput { + currentCliKind?: TerminalCliKind | null | undefined; + currentTitle?: string | null | undefined; + nextCliKind?: TerminalCliKind | null | undefined; + nextTitle: string; +} + +export function isGenericTerminalThreadTitle(title: string | null | undefined): boolean { + return (title ?? "").trim() === GENERIC_TERMINAL_THREAD_TITLE; +} + +const MAX_TERMINAL_INPUT_BUFFER_LENGTH = 512; +const MAX_TERMINAL_TITLE_LENGTH = 48; + +const WRAPPER_COMMANDS = new Set(["builtin", "command", "env", "noglob", "nocorrect", "sudo"]); +const CODEX_COMMAND_NAMES = new Set(["codex", "codex-cli"]); +const CLAUDE_COMMAND_NAMES = new Set(["claude", "claude-code", "claude_code"]); +const OUTPUT_CODEX_TEXT_PATTERNS = [/\bopenai codex\b(?:\s*\(|\s+v)/i, /\bcodex cli\b/i]; +const OUTPUT_CLAUDE_TEXT_PATTERNS = [/\bclaude code\b(?:\s+v\d|\s*$)/i]; +const TITLE_CODEX_TEXT_PATTERNS = [/\bopenai codex\b/i, /\bcodex cli\b/i]; +const TITLE_CLAUDE_TEXT_PATTERNS = [/\bclaude code\b/i]; +const PROCESS_CODEX_TEXT_PATTERNS = [/@openai\/codex/i]; +const PROCESS_CLAUDE_TEXT_PATTERNS = [/@anthropic-ai\/claude-code/i, /anthropic\/claude-code/i]; +const IGNORED_TERMINAL_TITLE_COMMANDS = new Set([ + ".", + "alias", + "cd", + "clear", + "exit", + "export", + "history", + "la", + "ll", + "logout", + "ls", + "pwd", + "reset", + "source", + "unalias", + "unset", +]); + +function truncateTerminalTitle(title: string): string { + return title.length <= MAX_TERMINAL_TITLE_LENGTH + ? title + : title.slice(0, MAX_TERMINAL_TITLE_LENGTH).trimEnd(); +} + +function normalizeTextForIdentityDetection(value: string): string { + return value + .replace(/\u001b\[[0-?]*[ -/]*[@-~]/g, " ") + .replace(/\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)/g, " ") + .replace(/\u001b[P^_].*?(?:\u001b\\|\u0007|\u009c)/g, " ") + .replace(/\u001b[@-_]/g, " ") + .replace(/[\u0000-\u001f\u007f-\u009f]/g, " ") + .replace(/\s+/g, " ") + .trim(); +} + +function normalizeCommandToken(token: string): string { + const normalizedPath = token.replaceAll("\\", "/"); + const segments = normalizedPath.split("/"); + for (let index = segments.length - 1; index >= 0; index -= 1) { + const segment = segments[index]; + if (segment) { + return segment.toLowerCase(); + } + } + return normalizedPath.toLowerCase(); +} + +function stripScriptExtension(token: string): string { + return token.replace(/\.(?:cjs|cts|js|jsx|mjs|mts|py|ts|tsx)$/i, ""); +} + +function deriveCliKindFromNormalizedToken(token: string): TerminalCliKind | null { + const normalizedToken = stripScriptExtension(token.trim().toLowerCase()); + if (normalizedToken.length === 0) { + return null; + } + if (CODEX_COMMAND_NAMES.has(normalizedToken) || normalizedToken === "@openai/codex") { + return "codex"; + } + if ( + CLAUDE_COMMAND_NAMES.has(normalizedToken) || + normalizedToken === "@anthropic-ai/claude-code" + ) { + return "claude"; + } + return null; +} + +function deriveCliKindFromTokenList(tokens: string[]): TerminalCliKind | null { + for (const token of tokens) { + const cliKind = deriveCliKindFromNormalizedToken(normalizeCommandToken(token)); + if (cliKind) { + return cliKind; + } + } + return null; +} + +function textMatchesCliPatterns( + text: string, + patterns: ReadonlyArray, + cliKind: TerminalCliKind, +): TerminalCliKind | null { + for (const pattern of patterns) { + if (pattern.test(text)) { + return cliKind; + } + } + return null; +} + +function deriveCliKindFromOutputText(text: string | null | undefined): TerminalCliKind | null { + const normalizedText = text?.trim(); + if (!normalizedText) { + return null; + } + return ( + textMatchesCliPatterns(normalizedText, OUTPUT_CODEX_TEXT_PATTERNS, "codex") ?? + textMatchesCliPatterns(normalizedText, OUTPUT_CLAUDE_TEXT_PATTERNS, "claude") + ); +} + +function deriveCliKindFromProcessText(text: string | null | undefined): TerminalCliKind | null { + const normalizedText = text?.trim(); + if (!normalizedText) { + return null; + } + return ( + textMatchesCliPatterns(normalizedText, PROCESS_CODEX_TEXT_PATTERNS, "codex") ?? + textMatchesCliPatterns(normalizedText, PROCESS_CLAUDE_TEXT_PATTERNS, "claude") + ); +} + +function isEnvAssignmentToken(token: string): boolean { + return /^[A-Za-z_][A-Za-z0-9_]*=.*/.test(token); +} + +function tokenizeShellCommand(command: string): string[] { + const tokens: string[] = []; + let current = ""; + let quote: "'" | '"' | null = null; + let escapeNext = false; + + for (const char of command.trim()) { + if (escapeNext) { + current += char; + escapeNext = false; + continue; + } + if (char === "\\") { + escapeNext = quote !== "'"; + if (!escapeNext) { + current += char; + } + continue; + } + if (quote !== null) { + if (char === quote) { + quote = null; + } else { + current += char; + } + continue; + } + if (char === "'" || char === '"') { + quote = char; + continue; + } + if (/\s/.test(char)) { + if (current.length > 0) { + tokens.push(current); + current = ""; + } + continue; + } + current += char; + } + + if (current.length > 0) { + tokens.push(current); + } + return tokens; +} + +function stripShellPrefixes(tokens: string[]): string[] { + let startIndex = 0; + while (startIndex < tokens.length && isEnvAssignmentToken(tokens[startIndex] ?? "")) { + startIndex += 1; + } + while ( + startIndex < tokens.length && + WRAPPER_COMMANDS.has(normalizeCommandToken(tokens[startIndex]!)) + ) { + startIndex += 1; + while (startIndex < tokens.length && isEnvAssignmentToken(tokens[startIndex] ?? "")) { + startIndex += 1; + } + } + return tokens.slice(startIndex); +} + +function unwrapExecutorCommand(tokens: string[]): string[] { + const [first, second, third] = tokens; + const normalizedFirst = normalizeCommandToken(first ?? ""); + const normalizedSecond = normalizeCommandToken(second ?? ""); + + if ((normalizedFirst === "npx" || normalizedFirst === "bunx") && second) { + return [second, ...tokens.slice(2)]; + } + if (normalizedFirst === "pnpm" && normalizedSecond === "dlx" && third) { + return [third, ...tokens.slice(3)]; + } + if (normalizedFirst === "npm" && normalizedSecond === "exec" && third) { + return [third, ...tokens.slice(3)]; + } + return tokens; +} + +function derivePackageManagerTitle(tokens: string[]): string | null { + const [first, second, third] = tokens.map(normalizeCommandToken); + if (!first || !["bun", "npm", "pnpm", "yarn"].includes(first)) { + return null; + } + if (second && ["create", "dlx", "exec", "run"].includes(second) && third) { + return `${first} ${second} ${third}`; + } + if (second) { + return `${first} ${second}`; + } + return first; +} + +function createTerminalCommandIdentity( + title: string, + cliKind: TerminalCliKind | null, +): TerminalCommandIdentity { + return { + cliKind, + iconKey: cliKind === "codex" ? "openai" : cliKind === "claude" ? "claude" : "terminal", + title, + }; +} + +export function defaultTerminalTitleForCliKind(cliKind: TerminalCliKind): string { + return cliKind === "codex" ? "Codex CLI" : "Claude Code"; +} + +export function managedTerminalCommandNameForCliKind(cliKind: TerminalCliKind): string { + return MANAGED_TERMINAL_COMMAND_NAME_BY_CLI_KIND[cliKind]; +} + +export function terminalCliKindFromValue(value: string | null | undefined): TerminalCliKind | null { + const normalizedValue = value?.trim().toLowerCase(); + return normalizedValue === "codex" || normalizedValue === "claude" ? normalizedValue : null; +} + +// Prefer the actual spawned process name over shell aliases when attributing terminal providers. +export function deriveTerminalProcessIdentity( + command: string | null | undefined, +): TerminalCommandIdentity | null { + const strippedCommand = command?.trim() ?? ""; + if (strippedCommand.length === 0) { + return null; + } + const tokenCliKind = + deriveCliKindFromTokenList(tokenizeShellCommand(strippedCommand)) ?? + deriveCliKindFromProcessText(strippedCommand); + if (tokenCliKind === "codex") { + return createTerminalCommandIdentity(defaultTerminalTitleForCliKind("codex"), "codex"); + } + if (tokenCliKind === "claude") { + return createTerminalCommandIdentity(defaultTerminalTitleForCliKind("claude"), "claude"); + } + return null; +} + +function inferCliKindFromTitle(title: string | null | undefined): TerminalCliKind | null { + const normalizedTitle = title?.trim().toLowerCase(); + if (!normalizedTitle) { + return null; + } + if (/^codex(?: cli)?(?: \d+)?$/.test(normalizedTitle)) { + return "codex"; + } + if (/^claude(?: code)?(?: \d+)?$/.test(normalizedTitle) || normalizedTitle === "claude-code") { + return "claude"; + } + return ( + textMatchesCliPatterns(normalizedTitle, TITLE_CODEX_TEXT_PATTERNS, "codex") ?? + textMatchesCliPatterns(normalizedTitle, TITLE_CLAUDE_TEXT_PATTERNS, "claude") + ); +} + +function normalizePersistedTerminalTitle( + title: string | null | undefined, + cliKind: TerminalCliKind | null, +): string { + const normalizedTitle = title?.trim(); + if (normalizedTitle && normalizedTitle.length > 0) { + return normalizedTitle; + } + return cliKind ? defaultTerminalTitleForCliKind(cliKind) : GENERIC_TERMINAL_THREAD_TITLE; +} + +// Convert a submitted shell command into a stable terminal identity for labels and icons. +export function deriveTerminalCommandIdentity(command: string): TerminalCommandIdentity | null { + const strippedCommand = command.trim(); + if (strippedCommand.length === 0) { + return null; + } + + const baseTokens = stripShellPrefixes(tokenizeShellCommand(strippedCommand)); + if (baseTokens.length === 0) { + return null; + } + + const tokens = unwrapExecutorCommand(baseTokens); + const normalizedTokens = tokens.map(normalizeCommandToken); + const first = normalizedTokens[0]; + const second = normalizedTokens[1]; + + if (!first || IGNORED_TERMINAL_TITLE_COMMANDS.has(first)) { + return null; + } + const detectedCliKind = deriveCliKindFromTokenList(tokens); + if (detectedCliKind === "codex") { + return createTerminalCommandIdentity("Codex CLI", "codex"); + } + if (detectedCliKind === "claude" || (first === "claude" && second === "code")) { + return createTerminalCommandIdentity("Claude Code", "claude"); + } + if (first === "git") { + return createTerminalCommandIdentity( + truncateTerminalTitle(second ? `git ${second}` : "git"), + null, + ); + } + + const packageManagerTitle = derivePackageManagerTitle(tokens); + if (packageManagerTitle) { + return createTerminalCommandIdentity(truncateTerminalTitle(packageManagerTitle), null); + } + + const genericTitle = normalizedTokens.slice(0, 2).join(" ").trim(); + return genericTitle.length > 0 + ? createTerminalCommandIdentity(truncateTerminalTitle(genericTitle), null) + : null; +} + +// Keep provider tabs sticky once a terminal is clearly a Codex/Claude session. +// Free-form prompts inside the CLI should not downgrade the icon/title back to a generic shell command. +export function reconcileTerminalCommandIdentity( + input: ReconcileTerminalCommandIdentityInput, +): TerminalCommandIdentity { + const nextIdentity = createTerminalCommandIdentity( + input.nextTitle.trim(), + input.nextCliKind ?? null, + ); + const currentCliKind = + input.currentCliKind === undefined + ? inferCliKindFromTitle(input.currentTitle) + : input.currentCliKind; + if (!currentCliKind) { + return nextIdentity; + } + if (nextIdentity.cliKind) { + return nextIdentity; + } + return createTerminalCommandIdentity( + normalizePersistedTerminalTitle(input.currentTitle, currentCliKind), + currentCliKind, + ); +} + +// Keep the legacy string-only helper for thread-title renames and narrow call sites. +export function deriveTerminalTitleFromCommand(command: string): string | null { + return deriveTerminalCommandIdentity(command)?.title ?? null; +} + +// Consume terminal input incrementally and emit terminal identity only when Enter submits a command. +export function consumeTerminalIdentityInput( + buffer: string, + data: string, +): { buffer: string; identity: TerminalCommandIdentity | null } { + if (data.includes("\u001b")) { + return { buffer, identity: null }; + } + + let nextBuffer = buffer; + let nextIdentity: TerminalCommandIdentity | null = null; + for (const char of data) { + if (char === "\r" || char === "\n") { + nextIdentity = deriveTerminalCommandIdentity(nextBuffer); + nextBuffer = ""; + continue; + } + if (char === "\b" || char === "\u007f") { + nextBuffer = nextBuffer.slice(0, -1); + continue; + } + if (char === "\t") { + nextBuffer += " "; + continue; + } + if (char === "\u0003" || char === "\u0004" || char === "\u0015") { + nextBuffer = ""; + continue; + } + if (char >= " ") { + nextBuffer += char; + } + } + + return { + buffer: nextBuffer.slice(-MAX_TERMINAL_INPUT_BUFFER_LENGTH), + identity: nextIdentity, + }; +} + +// Preserve the older title-only input API for server thread-title tracking. +export function consumeTerminalTitleInput( + buffer: string, + data: string, +): { buffer: string; title: string | null } { + const nextIdentityState = consumeTerminalIdentityInput(buffer, data); + return { + buffer: nextIdentityState.buffer, + title: nextIdentityState.identity?.title ?? null, + }; +} + +// Detect provider identity from CLI banners or other high-confidence visible output. +export function deriveTerminalOutputIdentity(output: string): TerminalCommandIdentity | null { + const cliKind = deriveCliKindFromOutputText(normalizeTextForIdentityDetection(output)); + return cliKind + ? createTerminalCommandIdentity(defaultTerminalTitleForCliKind(cliKind), cliKind) + : null; +} + +// Detect provider identity from terminal title signals without trusting the title as a tab name. +export function deriveTerminalTitleSignalIdentity(title: string): TerminalCommandIdentity | null { + const cliKind = inferCliKindFromTitle(title); + return cliKind + ? createTerminalCommandIdentity(defaultTerminalTitleForCliKind(cliKind), cliKind) + : null; +} + +// Resolve terminal label, icon, and activity state from persisted metadata plus runtime status. +export function resolveTerminalVisualIdentity(input: { + cliKind?: TerminalCliKind | null | undefined; + fallbackTitle: string; + isRunning?: boolean | undefined; + state?: TerminalVisualState | null | undefined; + title?: string | null | undefined; +}): ResolvedTerminalVisualIdentity { + const resolvedCliKind = input.cliKind ?? inferCliKindFromTitle(input.title); + const title = + input.title?.trim() || + (resolvedCliKind ? defaultTerminalTitleForCliKind(resolvedCliKind) : input.fallbackTitle); + const cliKind = resolvedCliKind ?? null; + const state = input.state ?? (input.isRunning ? "running" : "idle"); + return { + cliKind, + iconKey: cliKind === "codex" ? "openai" : cliKind === "claude" ? "claude" : "terminal", + state, + title, + }; +}