fix(setup): reject invalid OMP profiles before fallback writes (#1562)
This commit is contained in:
parent
a53689c55d
commit
e4cc0f09de
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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<string, ClientPathFailure>;
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
},
|
||||
);
|
||||
|
|
@ -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",
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in New Issue