Skip to content

Commit a8a6bee

Browse files
Prototype environment precedence using naturally inherited usernames
Configure USER and USERNAME on the existing default probe and observe the unconfigured data probe through native clients. This exercises replacement without requiring users to seed the client environment or change inheritance policy. Keep research evidence separate from production reports. Check missing and nonconflicting baselines, detect a reversed merge through an MCP exchange, and run the existing native CI on this temporary prototype branch.
1 parent 6409e11 commit a8a6bee

8 files changed

Lines changed: 268 additions & 7 deletions

File tree

‎.github/workflows/ci.yml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
name: CI
22
on:
33
push:
4-
branches: [main]
4+
branches: [main, agent/environment-precedence-prototype]
55
pull_request:
66
permissions:
77
contents: read

‎plugins/agent-plugins-conformance-core/dist/probe.mjs‎

Lines changed: 24 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎plugins/agent-plugins-conformance-core/mcp.json‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,9 @@
1818
"env": {
1919
"APC_VALUE": "fixture value with spaces",
2020
"APC_EXPANSION": "${PLUGIN_ROOT}|${PLUGIN_DATA}|${PLUGIN_ROOT}",
21-
"APC_LITERAL": "${APC_UNKNOWN}|$APC_VALUE|${PLUGIN_ROOT_SUFFIX}"
21+
"APC_LITERAL": "${APC_UNKNOWN}|$APC_VALUE|${PLUGIN_ROOT_SUFFIX}",
22+
"USER": "apc-configured-environment-precedence",
23+
"USERNAME": "apc-configured-environment-precedence"
2224
}
2325
},
2426
"relative": {

‎plugins/agent-plugins-conformance-core/src/probe.mjs‎

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ if (!['default', 'relative', 'root', 'data', 'command-token-posix', 'command-tok
1414
// This value is independent of the client-provided PLUGIN_ROOT environment.
1515
const root = realpathSync.native(fileURLToPath(new URL('..', import.meta.url)));
1616
const environmentNames = ['PLUGIN_ROOT', 'PLUGIN_DATA', 'APC_VALUE', 'APC_EXPANSION', 'APC_LITERAL'];
17+
const environmentPrecedenceNames = ['USER', 'USERNAME'];
1718
let resolvedData = null;
1819
if (process.env.PLUGIN_DATA && isAbsolute(process.env.PLUGIN_DATA)) {
1920
try { resolvedData = realpathSync.native(process.env.PLUGIN_DATA); } catch { /* Unobservable target remains null. */ }
@@ -38,7 +39,15 @@ const observeTool = {
3839
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
3940
annotations: { readOnlyHint: serverName !== 'default', destructiveHint: false, idempotentHint: serverName !== 'default', openWorldHint: false },
4041
};
41-
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [observeTool] }));
42+
const inspectEnvironmentPrecedenceTool = {
43+
name: 'inspect_environment_precedence',
44+
description: 'Research-only: return the exact candidate environment variables visible to this process. Do not record this result with the normal conformance reporter.',
45+
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
46+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
47+
};
48+
const tools = [observeTool];
49+
if (serverName === 'default' || serverName === 'data') tools.push(inspectEnvironmentPrecedenceTool);
50+
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools }));
4251
server.setRequestHandler(CallToolRequestSchema, async ({ params }) => {
4352
try {
4453
const args = params.arguments;
@@ -50,6 +59,23 @@ server.setRequestHandler(CallToolRequestSchema, async ({ params }) => {
5059
if (serverName === 'default') observation.evidence.dataWrite = observeDataWrite(process.env.PLUGIN_DATA);
5160
return { content: [{ type: 'text', text: JSON.stringify(observation) }], structuredContent: observation };
5261
}
62+
if (params.name === 'inspect_environment_precedence' &&
63+
(serverName === 'default' || serverName === 'data')) {
64+
if (!args || typeof args !== 'object' || Array.isArray(args) || Object.keys(args).length !== 0) {
65+
throw new Error('inspect_environment_precedence requires an empty object');
66+
}
67+
const observation = {
68+
kind: 'mcp-environment-precedence-research',
69+
server: serverName,
70+
evidence: {
71+
version: 1,
72+
env: Object.fromEntries(environmentPrecedenceNames
73+
.filter((name) => process.env[name] !== undefined)
74+
.map((name) => [name, process.env[name]])),
75+
},
76+
};
77+
return { content: [{ type: 'text', text: JSON.stringify(observation) }], structuredContent: observation };
78+
}
5379
throw new Error(`Unknown tool: ${params.name}`);
5480
} catch (error) {
5581
return { isError: true, content: [{ type: 'text', text: error.message }] };

‎scripts/environment-precedence.mjs‎

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
const STATUS = Object.freeze({ PASS: 'pass', FAIL: 'fail', NOT_VERIFIED: 'not_verified' });
2+
3+
export function evaluateEnvironmentPrecedence({ control, configured, expected }) {
4+
if (!control || !configured) {
5+
const missing = [!control && 'control', !configured && 'configured'].filter(Boolean);
6+
return {
7+
status: STATUS.NOT_VERIFIED,
8+
detail: `Missing ${missing.join(' and ')} observation.`,
9+
candidates: Object.entries(expected).map(([name, expectedValue]) => ({
10+
name,
11+
...(control && Object.hasOwn(control, name) ? { control: control[name] } : {}),
12+
...(configured && Object.hasOwn(configured, name) ? { configured: configured[name] } : {}),
13+
expected: expectedValue,
14+
status: STATUS.NOT_VERIFIED,
15+
detail: `The ${missing.join(' and ')} observation was unavailable.`,
16+
})),
17+
};
18+
}
19+
const candidates = Object.entries(expected).map(([name, expectedValue]) => {
20+
const controlPresent = Object.hasOwn(control, name);
21+
const configuredPresent = Object.hasOwn(configured, name);
22+
const values = {
23+
name,
24+
...(controlPresent ? { control: control[name] } : {}),
25+
...(configuredPresent ? { configured: configured[name] } : {}),
26+
expected: expectedValue,
27+
};
28+
if (!configuredPresent || configured[name] !== expectedValue) {
29+
return { ...values, status: STATUS.FAIL,
30+
detail: configuredPresent ? 'The configured value was not delivered.' : 'The configured variable was missing.' };
31+
}
32+
if (!controlPresent) {
33+
return { ...values, status: STATUS.NOT_VERIFIED,
34+
detail: 'The control process had no ambient value, so replacement was not observable.' };
35+
}
36+
if (control[name] === expectedValue) {
37+
return { ...values, status: STATUS.NOT_VERIFIED,
38+
detail: 'The control value already matched the configured value, so replacement was not observable.' };
39+
}
40+
return { ...values, status: STATUS.PASS,
41+
detail: 'The configured value replaced a different ambient control value.' };
42+
});
43+
const failed = candidates.filter(({ status }) => status === STATUS.FAIL).map(({ name }) => name);
44+
const passed = candidates.filter(({ status }) => status === STATUS.PASS).map(({ name }) => name);
45+
if (failed.length > 0) {
46+
return { status: STATUS.FAIL,
47+
detail: `Configured overrides were not delivered for: ${failed.join(', ')}.`, candidates };
48+
}
49+
if (passed.length > 0) {
50+
return { status: STATUS.PASS,
51+
detail: `Configured overrides replaced ambient values for: ${passed.join(', ')}.`, candidates };
52+
}
53+
return { status: STATUS.NOT_VERIFIED,
54+
detail: 'No candidate had a different ambient control value to replace.', candidates };
55+
}

‎scripts/smoke-codex.mjs‎

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,17 +8,20 @@ import { delimiter, dirname, isAbsolute, join, relative } from 'node:path';
88
import { createInterface } from 'node:readline';
99
import { fileURLToPath } from 'node:url';
1010
import { CASE_IDS } from '../plugins/agent-plugins-conformance/src/cases.mjs';
11+
import { evaluateEnvironmentPrecedence } from './environment-precedence.mjs';
1112

1213
const root = fileURLToPath(new URL('..', import.meta.url));
1314
const output = join(root, 'reports/codex');
1415
const reportPath = join(output, 'report.json');
16+
const environmentPrecedencePath = join(output, 'environment-precedence-research.json');
1517
const [codex, ...extra] = process.argv.slice(2);
1618
assert.ok(codex && isAbsolute(codex) && extra.length === 0,
1719
'Usage: node scripts/smoke-codex.mjs <absolute-codex-binary>');
1820
const names = ['agent-plugins-conformance-core', 'agent-plugins-conformance', 'agent-plugins-conformance-recovery'];
1921
const commandPlatform = process.platform === 'win32' ? 'windows' : 'posix';
2022
const commandServer = `command-token-${commandPlatform}`;
2123
const commandSymlinkServers = ['recovery-command-symlink-posix', 'recovery-command-symlink-windows'];
24+
const environmentPrecedenceNames = ['USER', 'USERNAME'];
2225
const servers = ['default', 'relative', 'root', 'data', commandServer, 'http', 'recovery-valid'];
2326
const invalidSseServers = [
2427
'recovery-sse-non-loopback', 'recovery-sse-relative-url', 'recovery-sse-fragment', 'recovery-sse-userinfo',
@@ -278,6 +281,52 @@ try {
278281
}
279282
record({ action: 'record', observation });
280283
}
284+
const environmentPrecedenceObservations = {};
285+
for (const server of ['data', 'default']) {
286+
const entry = status.data.find(({ name }) => name === server);
287+
assert.ok(entry && Object.hasOwn(entry.tools, 'inspect_environment_precedence'),
288+
`${server}: research environment precedence tool was not advertised`);
289+
const result = await rpc('mcpServer/tool/call', {
290+
threadId: thread.id, server, tool: 'inspect_environment_precedence', arguments: {},
291+
});
292+
assert.ok(!result.isError && !result.error,
293+
`${server}: inspect_environment_precedence failed: ${JSON.stringify(result)}`);
294+
const observation = result.structuredContent;
295+
assert.deepEqual(Object.keys(observation).sort(), ['evidence', 'kind', 'server']);
296+
assert.equal(observation.kind, 'mcp-environment-precedence-research');
297+
assert.equal(observation.server, server);
298+
assert.deepEqual(Object.keys(observation.evidence).sort(), ['env', 'version']);
299+
assert.equal(observation.evidence.version, 1);
300+
assert.ok(observation.evidence.env && typeof observation.evidence.env === 'object' &&
301+
!Array.isArray(observation.evidence.env), `${server}: expected an environment object`);
302+
assert.ok(Object.keys(observation.evidence.env).every((name) => environmentPrecedenceNames.includes(name)),
303+
`${server}: research tool returned a non-candidate environment key`);
304+
assert.ok(Object.values(observation.evidence.env).every((value) => typeof value === 'string'),
305+
`${server}: research tool returned a non-string environment value`);
306+
environmentPrecedenceObservations[server] = observation;
307+
}
308+
const expectedEnvironment = {
309+
USER: 'apc-configured-environment-precedence',
310+
USERNAME: 'apc-configured-environment-precedence',
311+
};
312+
const environmentPrecedenceEvaluation = evaluateEnvironmentPrecedence({
313+
control: environmentPrecedenceObservations.data.evidence.env,
314+
configured: environmentPrecedenceObservations.default.evidence.env,
315+
expected: expectedEnvironment,
316+
});
317+
await writeFile(environmentPrecedencePath, `${JSON.stringify({
318+
research: true,
319+
productionConformanceReport: false,
320+
note: 'Prototype evidence only; this result is intentionally excluded from the normal conformance reporter.',
321+
client: { name: 'Codex', version },
322+
platform: process.platform,
323+
candidates: environmentPrecedenceNames,
324+
control: environmentPrecedenceObservations.data,
325+
configured: environmentPrecedenceObservations.default,
326+
evaluation: environmentPrecedenceEvaluation,
327+
}, null, 2)}\n`);
328+
assert.equal(environmentPrecedenceEvaluation.status, 'pass',
329+
`Environment precedence research did not pass: ${environmentPrecedenceEvaluation.detail}`);
281330
// SSE is optional. Preserve native diagnostics even when the loader omits entries.
282331
const sseObservations = [];
283332
for (const server of ['sse', 'sse-header-precedence', 'sse-redirect', 'sse-endpoint-origin']) {
@@ -303,6 +352,8 @@ try {
303352
sseObservations.push(observation);
304353
}
305354
const report = JSON.parse(await readFile(reportPath, 'utf8'));
355+
assert.equal(report.observations.some(({ kind }) => kind === 'mcp-environment-precedence-research'), false,
356+
'Research-only environment evidence leaked into the normal conformance report');
306357
assert.deepEqual(report.observations.filter(({ kind, server }) => kind === 'mcp-sse' && !invalidSseServers.includes(server)), sseObservations,
307358
'SSE observations or native diagnostics were not preserved');
308359
const baselineSse = sseObservations[0].evidence;
@@ -343,6 +394,7 @@ try {
343394
`Native runtime connected through escaping executable symlinks: ${connectedCommandSymlinkServers.join(', ')}`);
344395
console.log(`Native Codex smoke passed: ${coveredCases.length} Core and Recovery cases; ${report.summary.not_verified} not verified.`);
345396
console.log(`Report: ${reportPath}`);
397+
console.log(`Environment precedence research (${environmentPrecedenceEvaluation.status}): ${environmentPrecedencePath}`);
346398
} catch (error) {
347399
console.error(`Native Codex smoke failed: ${error.message}`);
348400
process.exitCode = 1;
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import assert from 'node:assert/strict';
2+
import test from 'node:test';
3+
import { evaluateEnvironmentPrecedence } from '../scripts/environment-precedence.mjs';
4+
5+
test('environment precedence passes only for a delivered override with a conflicting baseline', () => {
6+
const result = evaluateEnvironmentPrecedence({
7+
control: { USER: 'ambient-user', USERNAME: 'ambient-username' },
8+
configured: { USER: 'fixture-user', USERNAME: 'fixture-username' },
9+
expected: { USER: 'fixture-user', USERNAME: 'fixture-username' },
10+
});
11+
assert.equal(result.status, 'pass');
12+
assert.deepEqual(result.candidates.map(({ name, status }) => [name, status]), [
13+
['USER', 'pass'], ['USERNAME', 'pass'],
14+
]);
15+
});
16+
17+
test('environment precedence fails a missing or mismatched configured value', () => {
18+
const result = evaluateEnvironmentPrecedence({
19+
control: { USER: 'ambient-user', USERNAME: 'ambient-username' },
20+
configured: { USER: 'ambient-user' },
21+
expected: { USER: 'fixture-user', USERNAME: 'fixture-username' },
22+
});
23+
assert.equal(result.status, 'fail');
24+
assert.deepEqual(result.candidates.map(({ name, status }) => [name, status]), [
25+
['USER', 'fail'], ['USERNAME', 'fail'],
26+
]);
27+
});
28+
29+
test('environment precedence is not verified when either process observation is missing', () => {
30+
for (const input of [
31+
{ control: null, configured: { USER: 'fixture-user' } },
32+
{ control: { USER: 'ambient-user' }, configured: null },
33+
]) {
34+
const result = evaluateEnvironmentPrecedence({ ...input, expected: { USER: 'fixture-user' } });
35+
assert.equal(result.status, 'not_verified');
36+
assert.equal(result.candidates[0].status, 'not_verified');
37+
}
38+
});
39+
40+
test('environment precedence is not verified without a conflicting ambient baseline', () => {
41+
const result = evaluateEnvironmentPrecedence({
42+
control: { USERNAME: 'fixture-username' },
43+
configured: { USER: 'fixture-user', USERNAME: 'fixture-username' },
44+
expected: { USER: 'fixture-user', USERNAME: 'fixture-username' },
45+
});
46+
assert.equal(result.status, 'not_verified');
47+
assert.deepEqual(result.candidates.map(({ name, status }) => [name, status]), [
48+
['USER', 'not_verified'], ['USERNAME', 'not_verified'],
49+
]);
50+
});
51+
52+
test('one wrong configured value fails the aggregate despite another genuine replacement', () => {
53+
const result = evaluateEnvironmentPrecedence({
54+
control: { USER: 'ambient-user', USERNAME: 'ambient-username' },
55+
configured: { USER: 'fixture-user', USERNAME: 'ambient-username' },
56+
expected: { USER: 'fixture-user', USERNAME: 'fixture-username' },
57+
});
58+
assert.equal(result.status, 'fail');
59+
assert.deepEqual(result.candidates.map(({ name, status }) => [name, status]), [
60+
['USER', 'pass'], ['USERNAME', 'fail'],
61+
]);
62+
});

0 commit comments

Comments
 (0)