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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion plugins/agent-plugins-conformance-core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@ Replace the path with a downloaded or installed copy of this plugin; the command
| `mcp.stdio.env.plugin-data-absolute` | Supplied `PLUGIN_DATA` is an absolute path. This check does not exercise writability, storage dedication, or persistence across updates. |
| `filesystem.data.writable` | The default server can create, write, and close a small temporary file directly in the supplied `PLUGIN_DATA` directory. |
| `filesystem.data.consistent-within-plugin` | Servers using omitted and data-rooted working directories resolve `PLUGIN_DATA` to the same filesystem path. |
| `mcp.stdio.env.configured-value` | The configured value reaches the subprocess. The fixture does not arrange a conflicting inherited value, so it does not establish override behavior. |
| `mcp.stdio.env.configured-value` | The configured value reaches the subprocess. |
| `mcp.stdio.env.configured-precedence` | Configured environment values replace conflicting base-environment values. |
| `mcp.stdio.args.preservation-and-expansion` | Argument boundaries, including spaces and an empty argument, are preserved; recognized placeholders expand and unknown placeholder-like text stays literal. |
| `mcp.stdio.command.single-token` | A command path containing a space is preserved as a single executable token. |
| `mcp.stdio.env.expansion` | Repeated recognized placeholders expand in environment values and unknown placeholder-like text stays literal. |
Expand Down
9 changes: 7 additions & 2 deletions plugins/agent-plugins-conformance-core/dist/probe.mjs

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion plugins/agent-plugins-conformance-core/mcp.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@
"env": {
"APC_VALUE": "fixture value with spaces",
"APC_EXPANSION": "${PLUGIN_ROOT}|${PLUGIN_DATA}|${PLUGIN_ROOT}",
"APC_LITERAL": "${APC_UNKNOWN}|$APC_VALUE|${PLUGIN_ROOT_SUFFIX}"
"APC_LITERAL": "${APC_UNKNOWN}|$APC_VALUE|${PLUGIN_ROOT_SUFFIX}",
"USER": "apc-configured-environment-precedence",
"USERNAME": "apc-configured-environment-precedence"
}
},
"relative": {
Expand Down
11 changes: 8 additions & 3 deletions plugins/agent-plugins-conformance-core/src/probe.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -14,25 +14,30 @@ if (!['default', 'relative', 'root', 'data', 'command-token-posix', 'command-tok
// This value is independent of the client-provided PLUGIN_ROOT environment.
const root = realpathSync.native(fileURLToPath(new URL('..', import.meta.url)));
const environmentNames = ['PLUGIN_ROOT', 'PLUGIN_DATA', 'APC_VALUE', 'APC_EXPANSION', 'APC_LITERAL'];
const environmentPrecedenceNames = ['USER', 'USERNAME'];
let resolvedData = null;
if (process.env.PLUGIN_DATA && isAbsolute(process.env.PLUGIN_DATA)) {
try { resolvedData = realpathSync.native(process.env.PLUGIN_DATA); } catch { /* Unobservable target remains null. */ }
}
const environment = Object.fromEntries(environmentNames.filter((name) => process.env[name] !== undefined)
.map((name) => [name, process.env[name]]));
if (['default', 'data'].includes(serverName)) {
for (const name of environmentPrecedenceNames) environment[name] = process.env[name] ?? null;
}
const launch = {
server: serverName,
root,
resolvedData,
// Compare directory identity even when the OS retains a junction/alias spelling.
cwd: realpathSync.native(process.cwd()),
argv: process.argv.slice(2),
env: Object.fromEntries(environmentNames.filter((name) => process.env[name] !== undefined)
.map((name) => [name, process.env[name]])),
env: environment,
};
const server = new Server({ name: `agent-plugins-conformance-${serverName}`, version: '0.1.0' },
{ capabilities: { tools: {} } });
const observeTool = {
name: 'observe',
description: 'Return this process launch evidence, including only fixture environment variables.' +
description: 'Return this process launch evidence, including selected environment variables.' +
(serverName === 'default' ? ' Each call also creates, writes, closes, and removes a uniquely named temporary file directly in an absolute PLUGIN_DATA directory, recording any operation or cleanup error.' : '') +
' Record the observation object from structuredContent (or parsed JSON text) unchanged with the run-conformance reporter; exclude the MCP result wrapper.',
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
Expand Down
1 change: 1 addition & 0 deletions plugins/agent-plugins-conformance/src/cases.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export const CASES = Object.freeze([
['filesystem.data.distinct-across-plugins', 'Distinct data directories across plugins', ['9.1']],
['filesystem.data.consistent-within-plugin', 'Consistent data directory within a plugin', ['9.1']],
['mcp.stdio.env.configured-value', 'Configured environment', ['9.1']],
['mcp.stdio.env.configured-precedence', 'Configured environment precedence', ['9.1']],
['mcp.stdio.command.single-token', 'Command token preservation', ['7.2.1']],
['mcp.stdio.args.preservation-and-expansion', 'Argument preservation and expansion', ['7.2.1', '9.2']],
['mcp.stdio.env.expansion', 'Environment expansion', ['9.2']],
Expand Down
43 changes: 41 additions & 2 deletions plugins/agent-plugins-conformance/src/report.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,12 @@ const SKILLS = {
'conformance-recovery-valid': 'APC_RECOVERY_VALID_V1',
'conformance-invalid-mcp-valid': 'APC_INVALID_MCP_VALID_V1',
};
const ENV_KEYS = ['PLUGIN_ROOT', 'PLUGIN_DATA', 'APC_VALUE', 'APC_EXPANSION', 'APC_LITERAL'];
const ENVIRONMENT_PRECEDENCE_KEYS = ['USER', 'USERNAME'];
const ENVIRONMENT_PRECEDENCE_VALUE = 'apc-configured-environment-precedence';
const ENV_KEYS = [
'PLUGIN_ROOT', 'PLUGIN_DATA', 'APC_VALUE', 'APC_EXPANSION', 'APC_LITERAL',
...ENVIRONMENT_PRECEDENCE_KEYS,
];

function invalid(at, reason) {
throw new TypeError(`${at}: ${reason}`);
Expand Down Expand Up @@ -295,7 +300,10 @@ export function validateInput(input, { recording = false } = {}) {
array(evidence.argv, `${evidenceAt}.argv`);
evidence.argv.forEach((argument, i) => string(argument, `${evidenceAt}.argv[${i}]`, true));
object(evidence.env, ENV_KEYS, [], `${evidenceAt}.env`);
for (const [key, value] of Object.entries(evidence.env)) string(value, `${evidenceAt}.env.${key}`, true);
for (const [key, value] of Object.entries(evidence.env)) {
if (ENVIRONMENT_PRECEDENCE_KEYS.includes(key) && value === null) continue;
string(value, `${evidenceAt}.env.${key}`, true);
}
if (observation.server === 'default' && evidence.dataWrite !== null) {
const at = `${evidenceAt}.dataWrite`;
const write = evidence.dataWrite;
Expand Down Expand Up @@ -716,6 +724,37 @@ export function buildReport(input) {
`default resolved PLUGIN_DATA to ${JSON.stringify(defaultData)}. data resolved PLUGIN_DATA to ${JSON.stringify(dataCwdData)}.`);
}
const evidence = runtime.get('default');
const precedenceCase = 'mcp.stdio.env.configured-precedence';
const environmentPrecedenceEvidence = Object.fromEntries(['default', 'data']
.map((server) => [server, runtime.get(server)]));
const missingPrecedenceServers = Object.entries(environmentPrecedenceEvidence)
.filter(([, value]) => value === undefined).map(([server]) => server);
const missingPrecedenceKeys = Object.entries(environmentPrecedenceEvidence).flatMap(([server, value]) => value === undefined
? []
: ENVIRONMENT_PRECEDENCE_KEYS.filter((key) => !Object.hasOwn(value.env, key))
.map((key) => `${server}.${key}`));
if (missingPrecedenceServers.length > 0) {
set(precedenceCase, 'not_verified',
`Missing MCP observation for: ${missingPrecedenceServers.join(', ')}.`);
} else if (missingPrecedenceKeys.length > 0) {
set(precedenceCase, 'not_verified',
`Environment precedence evidence is missing: ${missingPrecedenceKeys.join(', ')}.`);
} else {
const configuredDifferences = ENVIRONMENT_PRECEDENCE_KEYS
.filter((key) => environmentPrecedenceEvidence.default.env[key] !== ENVIRONMENT_PRECEDENCE_VALUE)
.map((key) => mismatch(key, ENVIRONMENT_PRECEDENCE_VALUE, environmentPrecedenceEvidence.default.env[key]));
if (configuredDifferences.length > 0) {
set(precedenceCase, 'fail',
`Configured environment values were not delivered. ${configuredDifferences.join(' ')}`);
} else {
const differingControls = ENVIRONMENT_PRECEDENCE_KEYS.filter((key) =>
typeof environmentPrecedenceEvidence.data.env[key] === 'string' &&
environmentPrecedenceEvidence.data.env[key] !== ENVIRONMENT_PRECEDENCE_VALUE);
set(precedenceCase, differingControls.length > 0 ? 'pass' : 'not_verified', differingControls.length > 0
? `Configured values replaced different inherited control values for: ${differingControls.join(', ')}.`
: 'No control value differed from the configured value, so replacement was not observable.');
}
}
if (evidence) {
const { root, env, argv } = evidence;
const flavor = pathFlavor(root);
Expand Down
40 changes: 38 additions & 2 deletions test/probe.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,9 @@ async function connect(fixture, mode, override = {}) {
const client = new Client({ name: 'conformance-reference-test', version: '1' });
const transport = new StdioClientTransport({
command: process.execPath, args: config.args.map(expand), cwd,
env: { ...variables, ...Object.fromEntries(Object.entries(config.env ?? {}).map(([key, value]) => [key, expand(value)])), APC_SHOULD_NOT_LEAK: 'unrelated ambient sentinel' },
env: { USER: 'ambient-user', USERNAME: 'ambient-username', ...variables,
...Object.fromEntries(Object.entries(config.env ?? {}).map(([key, value]) => [key, expand(value)])),
APC_SHOULD_NOT_LEAK: 'unrelated ambient sentinel' },
stderr: 'pipe', ...override,
});
fixture.clients.push(client);
Expand All @@ -61,6 +63,16 @@ test('copied plugin runs only MCP observation tools without node_modules', { tim
assert.deepEqual(result.structuredContent, observation);
assert.equal(observation.evidence.root, files.root);
assert.equal(observation.evidence.env.APC_SHOULD_NOT_LEAK, undefined);
if (mode === 'default') {
assert.equal(observation.evidence.env.USER, 'apc-configured-environment-precedence');
assert.equal(observation.evidence.env.USERNAME, 'apc-configured-environment-precedence');
} else if (mode === 'data') {
assert.equal(observation.evidence.env.USER, 'ambient-user');
assert.equal(observation.evidence.env.USERNAME, 'ambient-username');
} else {
assert.equal(Object.hasOwn(observation.evidence.env, 'USER'), false);
assert.equal(Object.hasOwn(observation.evidence.env, 'USERNAME'), false);
}
if (mode === 'default') {
assert.equal(dirname(observation.evidence.dataWrite.path), files.data);
assert.equal(observation.evidence.dataWrite.error, null);
Expand Down Expand Up @@ -93,10 +105,34 @@ test('copied plugin runs only MCP observation tools without node_modules', { tim
const reportInput = input(observations);
const direct = buildReport(reportInput);
assert.equal(direct.summary.fail, 0);
assert.equal(direct.summary.pass, 15);
assert.equal(direct.summary.pass, 16);
assert.equal(direct.summary.not_verified, 41);
});

test('faulty ambient precedence becomes a normal reporter failure for both platform variables', { timeout: 30_000 }, async (t) => {
const files = await fixture(t);
const variables = { PLUGIN_ROOT: files.root, PLUGIN_DATA: files.data };
const expand = (value) => value.replace(/\$\{(PLUGIN_ROOT|PLUGIN_DATA)\}/g, (_, name) => variables[name]);
const configured = Object.fromEntries(Object.entries(files.config.mcpServers.default.env)
.map(([name, value]) => [name, expand(value)]));
for (const name of ['USER', 'USERNAME']) {
const control = await connect(files, 'data', { env: {
...variables, USER: 'ambient-user', USERNAME: 'ambient-username',
} });
const faulty = await connect(files, 'default', { env: {
...variables, ...configured, [name]: `ambient-${name.toLowerCase()}`,
} });
const [controlResult, faultyResult] = await Promise.all([
control.callTool({ name: 'observe', arguments: {} }),
faulty.callTool({ name: 'observe', arguments: {} }),
]);
const report = buildReport(input([faultyResult.structuredContent, controlResult.structuredContent]));
const outcome = report.results.find(({ id }) => id === 'mcp.stdio.env.configured-precedence');
assert.equal(outcome.status, 'fail', name);
assert.match(outcome.detail, new RegExp(`${name}: expected`));
}
});

test('copied recovery plugin serves exact valid and invalid-server observations without runtime dependencies', { timeout: 30_000 }, async (t) => {
const parent = await mkdtemp(join(tmpdir(), 'agent-plugins-conformance-recovery-'));
const root = join(parent, 'copied recovery plugin');
Expand Down
24 changes: 24 additions & 0 deletions test/record-report.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,16 @@ const mcp = () => ({
env: {
PLUGIN_ROOT: '/plugin', PLUGIN_DATA: '/data', APC_VALUE: 'fixture value with spaces',
APC_EXPANSION: '/plugin|/data|/plugin', APC_LITERAL: '${APC_UNKNOWN}|$APC_VALUE|${PLUGIN_ROOT_SUFFIX}',
USER: 'apc-configured-environment-precedence', USERNAME: 'apc-configured-environment-precedence',
},
},
});
const dataMcp = (env = { USER: null, USERNAME: 'ambient-username' }) => ({
kind: 'mcp-stdio', server: 'data', evidence: {
version: 1, server: 'data', root: '/plugin', cwd: '/data', resolvedData: '/data',
argv: ['data'], env,
},
});
const commandToken = (platform = 'posix', {
root = platform === 'posix' ? '/plugin' : 'C:\\plugin',
data = platform === 'posix' ? '/data' : 'D:\\data',
Expand Down Expand Up @@ -393,6 +400,23 @@ test('distinct keys accumulate in deterministic order; repeated keys fully repla
assert.deepEqual(await readdir(join(f.directory, 'nested directory')), ['report with spaces.json']);
});

test('recorder preserves nullable environment precedence evidence through canonical reloads', async (t) => {
const f = await fixture(t);
f.success(start);
f.record(dataMcp());
f.record(mcp());
let report = await f.read();
assert.deepEqual(report.observations.filter(({ server }) => ['default', 'data'].includes(server)), [mcp(), dataMcp()]);
assert.equal(report.results.find(({ id }) => id === 'mcp.stdio.env.configured-precedence').status, 'pass');
assert.equal(report.observations.find(({ server }) => server === 'data').evidence.env.USER, null);

const oldData = dataMcp({});
f.record(oldData);
report = await f.read();
assert.deepEqual(report.observations.find(({ server }) => server === 'data'), oldData);
assert.equal(report.results.find(({ id }) => id === 'mcp.stdio.env.configured-precedence').status, 'not_verified');
});

test('command-token evidence records canonically and replacement changes only its verdict', async (t) => {
const f = await fixture(t);
f.success(start);
Expand Down
16 changes: 10 additions & 6 deletions test/report-human.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ import { formatReport } from '../plugins/agent-plugins-conformance/src/report-fo
function input() {
const root = '/fixture/plugin';
const data = '/state/plugin';
const precedenceEnv = (server) => server === 'default'
? { USER: 'apc-configured-environment-precedence', USERNAME: 'apc-configured-environment-precedence' }
: server === 'data' ? { USER: 'ambient-user', USERNAME: 'ambient-username' } : {};
return {
schemaVersion: 1,
observations: [
Expand All @@ -18,7 +21,8 @@ function input() {
cwd: server === 'default' ? root : server === 'data' ? data : `${root}/probe-workdir`,
argv: ['default', 'arg with spaces', '', root, data, '${APC_UNKNOWN}', '$APC_VALUE', '${PLUGIN_ROOT_SUFFIX}'],
env: { PLUGIN_ROOT: root, PLUGIN_DATA: data, APC_VALUE: 'fixture value with spaces',
APC_EXPANSION: `${root}|${data}|${root}`, APC_LITERAL: '${APC_UNKNOWN}|$APC_VALUE|${PLUGIN_ROOT_SUFFIX}' },
APC_EXPANSION: `${root}|${data}|${root}`, APC_LITERAL: '${APC_UNKNOWN}|$APC_VALUE|${PLUGIN_ROOT_SUFFIX}',
...precedenceEnv(server) },
},
})),
{
Expand Down Expand Up @@ -59,9 +63,9 @@ test('complete stdio and recovery evidence leaves optional HTTP and SSE checks u
'',
'Checks Passed Failed Not verified',
'Skills 3 0 0',
'MCP 22 0 26',
'MCP 23 0 26',
'Filesystem 4 0 1',
'Total 29 0 27',
'Total 30 0 27',
].join('\n'));
const missing = human.split('\n\nNot verified\n')[1];
for (const id of [
Expand Down Expand Up @@ -100,7 +104,7 @@ test('all-unverified human report preserves every saved missing-evidence result'
value.observations = [];
const human = formatReport(buildReport(value));
assert.match(human, /Skills\s+0\s+0\s+3/);
assert.match(human, /MCP\s+0\s+0\s+48/);
assert.match(human, /MCP\s+0\s+0\s+49/);
assert.match(human, /Filesystem\s+0\s+0\s+5/);
assert.match(human, /Missing skill observations: conformance-alpha, conformance-beta\./);
for (const result of buildReport(value).results) assert.ok(human.includes(`(${result.id})`));
Expand All @@ -112,7 +116,7 @@ test('mixed human report puts failures before missing-evidence details', () => {
value.observations = [value.observations[2]];
value.observations[0].evidence.env.APC_VALUE = 'incorrect configured value';
const human = formatReport(buildReport(value));
assert.match(human, /MCP\s+6\s+1\s+41/);
assert.match(human, /MCP\s+6\s+1\s+42/);
assert.match(human, /Filesystem\s+1\s+0\s+4/);
assert.match(human, /Configured environment \(mcp.stdio.env.configured-value\)/);
assert.match(human, /"fixture value with spaces"/);
Expand Down Expand Up @@ -169,7 +173,7 @@ test('human report renders saved warnings independently of result status', () =>
const writable = value.results.find(({ id }) => id === 'filesystem.data.writable');
writable.status = 'not_verified';
writable.warning = 'Saved cleanup warning for /state/plugin/leftover.';
value.summary = { pass: 16, fail: 0, not_verified: 40, total: 56 };
value.summary = { pass: 17, fail: 0, not_verified: 40, total: 57 };
value.observations = [];

const human = formatReport(value);
Expand Down
Loading
Loading