diff --git a/docs/testing.md b/docs/testing.md index 741bb6dc7..22e409afa 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -167,6 +167,23 @@ fix for that client/model limit. It preserves the complete catalog and schemas. Live model APIs, native Windows and default OS-home skill discovery remain unverified. +`verify:omp-client` requires installed OMP (verified with the official Linux x64 +18.8.7 binary); select it with `REA_VERIFY_OMP_COMMAND`. This optional POSIX lane +isolates the default agent, global config and XDG roots, guards setup targets, +preserves unrelated registrations and backups, and checks idempotence. It +compares native rejection of an invalid profile with REA's refusal to plan +fallback writes. OMP's default `xd://` interface mounts the complete REA catalog +as devices: call mode reads the complete installed skill and tool documentation, +then dispatches analysis through native `write`. It checks full named-schema +JavaScript Evidence with a Unicode path in the next model request. Device +metadata and documentation are distinct from forwarding all JSON schemas as +model functions. Native print-mode MCP readiness uses its defaults. +Use `-- chat` for ordinary chat or `REA_VERIFY_RUNTIME_ROOT` for a production-only +installed package. The loopback OpenAI completions/SSE model fixture declares a +one-million-token context and synthetic usage. Live model APIs, native Windows, +named-profile native execution and default OS-home skill discovery are +unverified; skill discovery uses an explicit isolated custom directory. + `verify:deepseek-client` requires an installed DeepSeek Harness (`dsh`; verified with `@deepseek-ai/dsh@0.2.0-rc.2`) and Git. Set `REA_VERIFY_DEEPSEEK_COMMAND` to select its executable. This optional POSIX lane diff --git a/package.json b/package.json index 7335b6f77..9e59d0c6e 100644 --- a/package.json +++ b/package.json @@ -182,6 +182,7 @@ "verify:qwen-client": "npm run build:cached && node scripts/verify-qwen-client.mjs", "verify:gemini-client": "npm run build:cached && node scripts/verify-gemini-client.mjs", "verify:opencode-client": "npm run build:cached && node scripts/verify-opencode-client.mjs", + "verify:omp-client": "npm run build:cached && node scripts/verify-omp-client.mjs", "verify:copilot-client": "npm run build:cached && node scripts/verify-copilot-client.mjs", "verify:claude-client": "npm run build:cached && node scripts/verify-claude-client.mjs", "verify:pi-client": "npm run build:cached && node scripts/verify-native-agent-client.mjs pi", diff --git a/scripts/verify-omp-client.mjs b/scripts/verify-omp-client.mjs new file mode 100644 index 000000000..ccf9f2ad0 --- /dev/null +++ b/scripts/verify-omp-client.mjs @@ -0,0 +1,441 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises"; +import { homedir, tmpdir } from "node:os"; +import { join, relative } from "node:path"; +import { fileURLToPath } from "node:url"; +import { + TOOL_CONTRACTS, + toolContract, +} from "../dist/contracts/toolContracts.js"; +import { exec } from "./lib/verify-package-core.mjs"; +import { createVerifierRun, completeVerifierRun } from "./lib/verifier-run.mjs"; +import { createOpenAiModelFixture } from "./verify/clients/model-fixture.mjs"; + +assert( + process.platform !== "win32", + "Native OMP verifier requires POSIX; Windows is unverified", +); +const repo = fileURLToPath(new URL("..", import.meta.url)); +const runtimeRoot = process.env.REA_VERIFY_RUNTIME_ROOT ?? repo; +const command = process.env.REA_VERIFY_OMP_COMMAND ?? "omp"; +const mode = process.argv[2] ?? "call"; +assert( + ["chat", "call"].includes(mode), + "Usage: verify-omp-client.mjs [chat|call]", +); +const verifier = createVerifierRun(); +const lab = await mkdtemp(join(tmpdir(), "rea-omp-client-")); +const account = join(lab, "account"); +const agent = join(lab, "selected agent"); +const workspace = join(lab, "workspace"); +const configPath = join(agent, "mcp.json"); +const skillDirectory = join( + account, + ".agents", + "skills", + "reverse-engineer-anything", +); +await Promise.all([ + mkdir(account), + mkdir(agent), + mkdir(workspace), + ...["data", "state", "cache"].map((name) => + mkdir(join(lab, name, "omp"), { recursive: true }), + ), +]); +await exec("git", ["init", "--quiet", workspace], { timeout: 30_000 }); +// OMP's global config root uses native homedir independently of the agent override. +// A documented relative PI_CONFIG_DIR redirects it without changing HOME. +const environment = { + PATH: process.env.PATH, + NODE_OPTIONS: process.env.NODE_OPTIONS, + USERPROFILE: account, + PI_CODING_AGENT_DIR: agent, + PI_CONFIG_DIR: relative(homedir(), join(lab, "native-config")), + OMP_PROFILE: "", + XDG_DATA_HOME: join(lab, "data"), + XDG_STATE_HOME: join(lab, "state"), + XDG_CACHE_HOME: join(lab, "cache"), + REA_PROCESS_RUN_ID: verifier.run_id, +}; +const target = join(lab, "fixture α测试"); +await mkdir(target); +await writeFile( + join(target, "app.js"), + 'export function result() { return { clientCompatibility: "REA_CLIENT_FIXTURE", count: 7 }; }\n', +); +const expectedDevices = TOOL_CONTRACTS.map( + (tool) => "mcp__rea_" + tool.name, +).sort(); +const analysisDevice = "xd://mcp__rea_analyze_javascript_application"; +let clientVersion; +let expectedSkillBody; +let skillLoaded = false; +let contractRead = false; +let evidenceSeen = false; +let artifactDigest; +let finalSeen = false; +let catalogMounted = false; +let invalidProfileRejected = false; +let failure; +const containsText = (value, text) => { + if (typeof value === "string") return value.includes(text); + return ( + value !== null && + typeof value === "object" && + Object.values(value).some((item) => containsText(item, text)) + ); +}; +const call = (index, name, args) => ({ + role: "assistant", + content: null, + tool_calls: [ + { + id: `call_omp_${index}`, + type: "function", + function: { name, arguments: JSON.stringify(args) }, + }, + ], +}); +const fixture = await createOpenAiModelFixture({ + directory: lab, + prefix: "omp", + onRequest: async ({ body, tools, requestIndex }) => { + assert(tools.some((tool) => tool.name === "read")); + assert(tools.some((tool) => tool.name === "write")); + const instructions = body.messages.filter( + (message) => message.role === "system", + ); + for (const device of expectedDevices) + assert( + containsText(instructions, device), + `OMP must advertise mounted device ${device}`, + ); + if (mode === "chat") { + finalSeen = true; + return { role: "assistant", content: "REA_CLIENT_COMPATIBILITY_OK" }; + } + const replies = body.messages.filter((message) => message.role === "tool"); + if (requestIndex === 1) + return call(requestIndex, "read", { + i: "Load REA skill", + path: "skill://reverse-engineer-anything:raw", + }); + assert( + expectedSkillBody !== undefined && + containsText(replies, expectedSkillBody), + "OMP must return the complete installed skill body", + ); + skillLoaded = true; + if (requestIndex === 2) + return call(requestIndex, "read", { + i: "Inspect REA native tool contract", + path: analysisDevice, + }); + assert(containsText(replies, "# mcp__rea_analyze_javascript_application")); + assert( + containsText(replies, "input_path") && + containsText(replies, '"directory"'), + ); + contractRead = true; + if (requestIndex === 3) + return call(requestIndex, "write", { + path: analysisDevice, + content: JSON.stringify({ input_path: target, format: "directory" }), + }); + const evidenceReply = replies.find( + (reply) => + typeof reply.content === "string" && + reply.content.startsWith('{"evidence_id":'), + ); + assert( + evidenceReply, + "OMP must deliver the full analysis result to the next model turn", + ); + const evidence = toolContract( + "analyze_javascript_application", + ).outputSchema.parse(JSON.parse(evidenceReply.content)); + assert.equal(evidence.subject.local_path, target); + const shape = evidence.normalized_result.graph.nodes + .flatMap((node) => node.observations) + .map((observation) => observation.properties) + .find( + (properties) => + properties.semantic_role === "export-return-shapes" && + properties.exported_name === "result", + ); + assert(shape?.return_shape_coverage?.projection_complete); + const fields = shape.static_return_shapes.flatMap((item) => item.fields); + assert( + fields.some( + (field) => + field.path === "/clientCompatibility" && + field.state === "literal" && + field.value === "REA_CLIENT_FIXTURE", + ), + ); + assert( + fields.some( + (field) => + field.path === "/count" && + field.state === "literal" && + field.value === 7, + ), + ); + const bytes = JSON.stringify(evidence); + artifactDigest = createHash("sha256").update(bytes).digest("hex"); + await writeFile(join(lab, "omp-evidence.json"), bytes); + evidenceSeen = true; + finalSeen = true; + return { role: "assistant", content: "REA_CLIENT_COMPATIBILITY_OK" }; + }, +}); +const original = JSON.stringify( + { + mcpServers: { other: { command: "unrelated-server", enabled: false } }, + disabledServers: ["rea", "other"], + }, + null, + 2, +); +await writeFile(configPath, original); +await writeFile( + join(agent, "config.yml"), + JSON.stringify({ + skills: { customDirectories: [join(account, ".agents", "skills")] }, + retry: { fallbackChains: { default: [] } }, + }), +); +await writeFile( + join(agent, "models.yml"), + JSON.stringify({ + providers: { + "rea-fixture": { + baseUrl: fixture.baseUrl, + apiKey: "local-fixture-key", + api: "openai-completions", + models: [ + { + id: "rea-client-fixture", + name: "REA local fixture", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 1000000, + maxTokens: 4096, + }, + ], + }, + }, + }), +); +const rea = async (args, env = environment) => + exec( + process.execPath, + [join(runtimeRoot, "scripts/rea.mjs"), ...args, "--json"], + { env, cwd: workspace, timeout: 60_000 }, + ); +try { + clientVersion = ( + await exec(command, ["--version"], { env: environment, timeout: 30_000 }) + ).stdout.trim(); + const invalidEnvironment = { ...environment, OMP_PROFILE: "Bad Name" }; + const nativeRejected = await exec( + command, + ["--print", "--no-session", "--no-title", "Hello"], + { env: invalidEnvironment, cwd: workspace, timeout: 30_000 }, + ).then( + () => undefined, + (cause) => cause, + ); + assert( + nativeRejected?.code === 1 && + nativeRejected.stderr.includes('Invalid OMP profile "Bad Name"'), + ); + await writeFile( + join(lab, "invalid-profile-native.stderr"), + nativeRejected.stderr, + ); + const setupRejected = await rea( + ["setup", "--client", "omp", "--dry-run"], + invalidEnvironment, + ).then( + () => undefined, + (cause) => cause, + ); + assert(typeof setupRejected?.code === "number" && setupRejected.code !== 0); + const rejected = JSON.parse(setupRejected.stdout); + assert.equal(rejected.status, "needs_human"); + assert.deepEqual(rejected.plannedActions, []); + assert.deepEqual(rejected.appliedActions, []); + assert(rejected.remediation.includes('Invalid OMP profile "Bad Name"')); + assert.equal(await readFile(configPath, "utf8"), original); + await writeFile( + join(lab, "invalid-profile-setup.json"), + setupRejected.stdout, + ); + invalidProfileRejected = true; + for (const args of [ + ["setup", "--client", "omp", "--dry-run"], + ["setup", "--client", "omp", "--yes"], + ["doctor", "--client", "omp", "--skill"], + ]) { + const result = await rea(args); + await writeFile( + join(lab, `${args[0]}${args.includes("--dry-run") ? "-plan" : ""}.json`), + result.stdout, + ); + const parsed = JSON.parse(result.stdout); + if (args[0] === "doctor") assert.equal(parsed.healthy, true); + else { + assert.equal( + parsed.status, + args.includes("--dry-run") ? "planned" : "ready", + ); + if (args.includes("--dry-run")) { + assert( + parsed.plannedActions.some((action) => action.target === configPath), + ); + assert( + parsed.plannedActions.some( + (action) => action.target === skillDirectory, + ), + ); + assert( + parsed.plannedActions.every( + (action) => + action.target === configPath || + action.target === skillDirectory || + action.target.startsWith(skillDirectory + "/"), + ), + "Refuse setup writes outside the owned config/skill", + ); + } + } + } + assert.equal(await readFile(`${configPath}.rea.backup`, "utf8"), original); + const configured = await readFile(configPath, "utf8"); + const registration = JSON.parse(configured); + assert.equal(registration.mcpServers.other.command, "unrelated-server"); + assert.deepEqual(registration.disabledServers, ["other"]); + const repeated = JSON.parse( + (await rea(["setup", "--client", "omp", "--yes"])).stdout, + ); + assert.deepEqual(repeated.appliedActions, []); + assert.equal(await readFile(configPath, "utf8"), configured); + assert.equal(await readFile(`${configPath}.rea.backup`, "utf8"), original); + expectedSkillBody = await readFile(join(skillDirectory, "SKILL.md"), "utf8"); + const execution = exec( + command, + [ + "--mode", + "json", + "--print", + "--no-session", + "--no-title", + "--no-lsp", + "--no-pty", + "--approval-mode", + "yolo", + "--model", + "rea-fixture/rea-client-fixture", + mode === "chat" + ? "Say hello briefly." + : `Use the REA skill to analyze ${target}.`, + ], + { + env: environment, + cwd: workspace, + timeout: 120_000, + maxBuffer: 10 * 1024 * 1024, + }, + ); + execution.child.stdin.end(); + const response = await execution; + await writeFile(join(lab, `${mode}.stdout`), response.stdout); + await writeFile(join(lab, `${mode}.stderr`), response.stderr); + assert.equal(fixture.failure, undefined); + const events = response.stdout + .trim() + .split("\n") + .map((line) => JSON.parse(line)); + const mounted = events + .filter( + (event) => + event.type === "notice" && event.message?.startsWith("xd://: mounted "), + ) + .flatMap((event) => + event.message.slice("xd://: mounted ".length).split(", "), + ) + .filter((name) => name.startsWith("mcp__rea_")); + assert.deepEqual( + mounted.sort(), + expectedDevices, + "Native OMP must mount the complete REA catalog", + ); + catalogMounted = true; + assert( + !events.some( + (event) => event.type === "tool_execution_end" && event.isError, + ), + ); + assert( + events.some( + (event) => + event.type === "message_end" && + event.message?.role === "assistant" && + event.message.stopReason === "stop" && + containsText(event.message.content, "REA_CLIENT_COMPATIBILITY_OK"), + ), + "OMP must finish with the expected native assistant message", + ); + assert( + events.some((event) => event.type === "agent_end" && event.isTerminal), + ); + assert(finalSeen); + if (mode === "call") assert(skillLoaded && contractRead && evidenceSeen); +} catch (cause) { + failure = cause; + if (typeof cause?.stdout === "string") + await writeFile(join(lab, `${mode}.stdout`), cause.stdout); + if (typeof cause?.stderr === "string") + await writeFile(join(lab, `${mode}.stderr`), cause.stderr); +} finally { + await fixture.close(); + const lineage = await completeVerifierRun(verifier); + const receipt = { + client: "OMP", + clientVersion, + mode, + passed: failure === undefined, + artifacts: lab, + runtimeRoot, + host: { + platform: process.platform, + architecture: process.arch, + node: process.versions.node, + }, + modelFixture: + "loopback OpenAI completions/SSE, caller-declared one-million-token model and synthetic usage; no live model claim", + discovery: + "explicit isolated default agent/global config/XDG roots and custom skill directory; named profiles, default OS-home skill discovery and Windows unverified", + interface: + "default native xd:// device metadata with read/write dispatch; complete JSON schemas are not forwarded as model functions", + catalogMounted, + catalogSize: expectedDevices.length, + invalidProfileRejected, + skillLoaded, + contractRead, + evidenceSeen, + artifactDigest, + finalSeen, + requests: fixture.requests, + probes: fixture.probes, + fixtureFailure: fixture.failure, + verifier: lineage, + }; + await writeFile(join(lab, "receipt.json"), JSON.stringify(receipt, null, 2)); + console.log(JSON.stringify(receipt)); +} +if (failure !== undefined) throw failure; diff --git a/src/application/SupportedClients.ts b/src/application/SupportedClients.ts index c87925f98..915458047 100644 --- a/src/application/SupportedClients.ts +++ b/src/application/SupportedClients.ts @@ -1,6 +1,7 @@ import { dirname, isAbsolute, join, resolve, win32 } from "node:path"; import { fileURLToPath } from "node:url"; import { lstatSync } from "node:fs"; +import { err, ok, type Result } from "../domain/result.js"; import { HermesClientPathError, resolveHermesClientHome, @@ -39,7 +40,15 @@ export interface SetupClient { | "unsupported"; } -type ClientPath = readonly string[] | ((paths: ClientPathContext) => string); +interface ClientPathFailure { + readonly path: string; + readonly message: string; +} + +type ClientPathResult = Result; +type ClientPath = + | readonly string[] + | ((paths: ClientPathContext) => string | ClientPathResult); interface ClientPathContext { readonly home: string; @@ -146,34 +155,32 @@ export const manualRegistrationRemediation = ( : undefined; const OMP_PROFILE_NAME = /^[a-z0-9][a-z0-9._-]{0,63}$/u; - -/** - * OMP's active named profile. OMP_PROFILE wins over the legacy PI_PROFILE even - * when empty; empty and "default" select the default profile. OMP refuses to - * start with an invalid name, so one leaves the default location in place. - */ -const ompProfile = ({ env }: ClientPathContext): string | undefined => { - const name = (env.OMP_PROFILE ?? env.PI_PROFILE)?.trim(); - return name === undefined || - name === "" || - name === "default" || - name.endsWith(".") || - !OMP_PROFILE_NAME.test(name) - ? undefined - : name; -}; +const OMP_RESERVED_PROFILE = + /^(?:CON|PRN|AUX|NUL|COM[0-9]|LPT[0-9])(?:\..*)?$/iu; /** * The OMP agent directory whose `mcp.json` holds user-scope MCP servers. OMP * joins PI_CONFIG_DIR to the home directory, and a named profile ignores * PI_CODING_AGENT_DIR. Relative overrides use the setup process working directory. */ -const ompAgentDirectory = (context: ClientPathContext): string => { +const ompAgentDirectory = (context: ClientPathContext): ClientPathResult => { const root = join(context.home, context.env.PI_CONFIG_DIR || ".omp"); - const profile = ompProfile(context); - if (profile !== undefined) return join(root, "profiles", profile, "agent"); + // OMP_PROFILE wins even when empty. Match OMP's validation on every host. + const profile = (context.env.OMP_PROFILE ?? context.env.PI_PROFILE)?.trim(); + if (profile !== undefined && profile !== "" && profile !== "default") { + if ( + !OMP_PROFILE_NAME.test(profile) || + profile.endsWith(".") || + OMP_RESERVED_PROFILE.test(profile) + ) + return err({ + path: root, + message: `Invalid OMP profile ${JSON.stringify(profile)}: names must match ^[a-z0-9][a-z0-9._-]{0,63}$, cannot end with ".", and cannot be a Windows reserved device name (CON, PRN, AUX, NUL, COM0-9, LPT0-9, including extensions). Repair OMP_PROFILE or PI_PROFILE before configuring OMP.`, + }); + return ok(join(root, "profiles", profile, "agent")); + } const override = context.env.PI_CODING_AGENT_DIR; - return override ? resolve(override) : join(root, "agent"); + return ok(override ? resolve(override) : join(root, "agent")); }; /** @@ -456,8 +463,10 @@ export const SUPPORTED_CLIENT_DEFINITIONS = [ { name: "omp", displayName: "OMP", - configPath: (context: ClientPathContext) => - join(ompAgentDirectory(context), "mcp.json"), + configPath: (context: ClientPathContext) => { + const directory = ompAgentDirectory(context); + return directory.ok ? ok(join(directory.value, "mcp.json")) : directory; + }, markerPath: ompAgentDirectory, format: "omp", }, @@ -508,8 +517,14 @@ export const clientEvidencePaths = ( ), ]; -const resolvePath = (path: ClientPath, context: ClientPathContext): string => - typeof path === "function" ? path(context) : join(context.home, ...path); +const resolvePath = ( + path: ClientPath, + context: ClientPathContext, +): ClientPathResult => { + const resolved = + typeof path === "function" ? path(context) : join(context.home, ...path); + return typeof resolved === "string" ? ok(resolved) : resolved; +}; /** Describe every client location that setup, doctor, or uninstall may inspect. */ export const supportedClients = ( @@ -521,7 +536,27 @@ export const supportedClients = ( const definitions: readonly ClientDefinition[] = SUPPORTED_CLIENT_DEFINITIONS; return definitions.map((definition) => { try { - const configPath = resolvePath(definition.configPath, context); + const config = resolvePath(definition.configPath, context); + const marker = config.ok + ? resolvePath(definition.markerPath, context) + : config; + if (!config.ok) + return { + name: definition.name, + displayName: definition.displayName, + format: definition.format, + configPath: config.error.path, + configPathError: config.error.message, + }; + if (!marker.ok) + return { + name: definition.name, + displayName: definition.displayName, + format: definition.format, + configPath: marker.error.path, + configPathError: marker.error.message, + }; + const configPath = config.value; return { name: definition.name, displayName: definition.displayName, @@ -536,7 +571,7 @@ export const supportedClients = ( ...new Set([...definition.configPaths(context), configPath]), ], }), - markerPath: resolvePath(definition.markerPath, context), + markerPath: marker.value, format: definition.format, }; } catch (cause: unknown) { @@ -584,14 +619,16 @@ export const clientSkillDirectories = ( const definition = definitions.find(({ name }) => name === client); let directory: string; try { - directory = + const resolved = definition?.skillPath === undefined - ? join(home, ".agents", "skills") + ? ok(join(home, ".agents", "skills")) : resolvePath(definition.skillPath, { home, platform, env: environment, }); + if (!resolved.ok) continue; + directory = resolved.value; } catch (cause: unknown) { // Registration diagnostics own an unresolved profile; never fall back to another skill root. if (cause instanceof HermesClientPathError) continue; diff --git a/tests/acceptance/setup/ompClientSetup.test.ts b/tests/acceptance/setup/ompClientSetup.test.ts new file mode 100644 index 000000000..1a1ddfa9c --- /dev/null +++ b/tests/acceptance/setup/ompClientSetup.test.ts @@ -0,0 +1,112 @@ +import { access, mkdir, readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect } from "vitest"; + +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; + +// Match native profile rules, including reserved names on POSIX. +for (const [variable, profile] of [ + ["OMP_PROFILE", "Bad Name"], + ["OMP_PROFILE", "../escape"], + ["OMP_PROFILE", "trailing."], + ["OMP_PROFILE", "con.txt"], + ["OMP_PROFILE", "com0"], + ["OMP_PROFILE", "lpt0"], + ["OMP_PROFILE", "a".repeat(65)], + ["PI_PROFILE", "Bad Name"], +] as const) + cliTest( + `OMP rejects invalid ${variable} ${profile} without writing a fallback`, + async ({ cli }) => { + const home = await createTestTempDirectory("rea-omp-invalid-profile-"); + const agent = join(home, "selected agent"); + const config = join(agent, "mcp.json"); + const original = '{"mcpServers":{"other":{"command":"other-server"}}}\n'; + await mkdir(agent); + await writeFile(config, original); + const run = (args: readonly string[], profile: string) => + cli.run({ + arguments: args, + cwd: home, + environment: { + USERPROFILE: home, + PI_CODING_AGENT_DIR: agent, + [variable]: profile, + }, + timeoutMs: 20_000, + }); + for (const option of ["--dry-run", "--yes"]) { + const rejected = await run( + ["setup", "--client", "omp", option, "--json"], + profile, + ); + expect(rejected.exitCode).not.toBe(0); + expect(rejected.json).toMatchObject({ + status: "needs_human", + plannedActions: [], + appliedActions: [], + remediation: expect.stringContaining( + `Invalid OMP profile ${JSON.stringify(profile)}`, + ), + }); + } + expect(await readFile(config, "utf8")).toBe(original); + for (const path of [ + config + ".rea.backup", + join(home, ".omp", "agent"), + join(home, ".agents", "skills"), + ]) + await expect(access(path)).rejects.toMatchObject({ code: "ENOENT" }); + + if (profile !== "con.txt") return; + const doctor = await run( + ["doctor", "--client", "omp", "--json"], + "con.txt", + ); + expect(doctor.exitCode).not.toBe(0); + expect(doctor.json).toMatchObject({ + identity: { + registrations: expect.arrayContaining([ + expect.objectContaining({ + client: "omp", + state: "invalid", + remediation: expect.stringContaining( + "Windows reserved device name", + ), + }), + ]), + }, + }); + const unrelated = await run( + ["setup", "--client", "qwen_code", "--yes", "--json"], + "Bad Name", + ); + expect(unrelated.exitCode).toBe(0); + expect(unrelated.json).toMatchObject({ status: "ready" }); + }, + ); + +cliTest( + "OMP explicit default selection overrides an invalid legacy profile", + async ({ cli }) => { + const home = await createTestTempDirectory("rea-omp-default-profile-"); + const agent = join(home, "agent"); + const result = await cli.run({ + arguments: ["setup", "--client", "omp", "--yes", "--json"], + cwd: home, + environment: { + USERPROFILE: home, + PI_CODING_AGENT_DIR: agent, + OMP_PROFILE: " ", + PI_PROFILE: "Bad Name", + }, + timeoutMs: 20_000, + }); + expect(result.exitCode).toBe(0); + expect(result.json).toMatchObject({ status: "ready" }); + expect( + JSON.parse(await readFile(join(agent, "mcp.json"), "utf8")), + ).toHaveProperty("mcpServers.rea"); + }, +); diff --git a/tests/boundary/filesystem/ompClientConfiguration.test.ts b/tests/boundary/filesystem/ompClientConfiguration.test.ts index 7a961e08d..bb5928a8d 100644 --- a/tests/boundary/filesystem/ompClientConfiguration.test.ts +++ b/tests/boundary/filesystem/ompClientConfiguration.test.ts @@ -56,7 +56,7 @@ describe("OMP configuration paths", () => { expect(ompPath({ OMP_PROFILE: "", PI_PROFILE: "legacy" })?.configPath).toBe( "/home/a/.omp/agent/mcp.json", ); - for (const profile of ["default", " ", "Bad Name", "trailing."]) + for (const profile of ["default", " "]) expect(ompPath({ OMP_PROFILE: profile })?.configPath).toBe( "/home/a/.omp/agent/mcp.json", );