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
13 changes: 13 additions & 0 deletions .changeset/codex-mcp-path-token-lowering-row.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
"agent-bundle": patch
---

Record the Codex MCP `${PLUGIN_ROOT}` lowering rule as a dated
`mcp.pathTokenLowering` row in the pinned `codex-0.147.0.json` capability
table (no host interpolation; a *leading* `${PLUGIN_ROOT}` in `command`,
`args`, `env` values, and `cwd` is rewritten to a `./`-relative path under
`cwd: "./"` only when `cwd` is the plugin root; embedded tokens,
`${PLUGIN_DATA}`, and workspace-root tokens fail the build), so the generated
hosts reference renders it from the table instead of a hardcoded note. The
`codex` target's `adapterRevision` advances to `1.12.0`, so previously built
Codex artifacts revalidate as stale against the changed table. (#431)
3 changes: 1 addition & 2 deletions examples/rsc-agent-runtime/rstest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,8 @@ export default defineConfig({
// Route-unit proofs need the framework-generated configuration
// (rstest.route-unit.config.ts via `pnpm test:routes`), so they are not
// swept into this plain Node pool where no test manifest is registered.
exclude: ['tests/route-unit/**'],
include: ['tests/**/*.test.{ts,tsx}'],
exclude: ['tests/route-unit/**/*.test.{ts,tsx}'],
exclude: ['tests/route-unit/**'],
pool: { maxWorkers: 1 },
testEnvironment: 'node',
// Every suite here runs real rsbuild compiles and spawned children, which a
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -395,6 +395,15 @@
}
},
"mcp": {
"pathTokenLowering": {
"evidence": [
"2026-09-03: plugin.hookEnvironment records PLUGIN_ROOT and PLUGIN_DATA as environment variables Codex exports to plugin hook commands; the pinned schemas/codex/mcp.schema.json declares no placeholder or interpolation rule for .mcp.json command, args, env, or cwd values, so this table records no mcp.pathTokens field list for Codex.",
"2026-09-03: packages/agent-bundle/src/adapters/codex.ts lowers a leading ${PLUGIN_ROOT} in command, args, env values, and cwd to a ./-relative path under cwd \"./\" only when the server's cwd is the plugin root, and fails the build for embedded plugin-root tokens (codex.mcp.token.plugin-root.embedded.*), plugin-root tokens without a plugin-root cwd (codex.mcp.token.plugin-root.cwd.required.*), ${PLUGIN_DATA} (codex.mcp.token.plugin-data.*), and workspace-root tokens (codex.mcp.token.workspace-root.*); this row is what the generated hosts reference renders in place of the token-field list."
],
"fields": ["command", "args", "env", "cwd"],
"reason": "No host interpolation. `command`, `args`, `env` values, and `cwd` accept a *leading* `${PLUGIN_ROOT}` only when `cwd` is the plugin root; the compiler rewrites it to a `./`-relative path under `cwd: \"./\"`. Embedded tokens, `${PLUGIN_DATA}`, and workspace-root tokens are build errors.",
"state": "degraded"
},
"stdio": true,
"streamableHttp": true
},
Expand Down
2 changes: 1 addition & 1 deletion packages/agent-bundle/src/adapters/codex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,7 @@ const hookContract = Object.freeze({
wrapperSource: (entry) => nativeHookWrapperSource(entry, 'Codex'),
} satisfies TargetHookContract);
const metadata = Object.freeze({
adapterRevision: '1.11.0',
adapterRevision: '1.12.0',
observedVersion: capabilityTable.observedCliVersion,
schemas: schemaDescriptorsFrom(schemaProvenance, schemaProvenance.observedCliVersion),
});
Expand Down
2 changes: 1 addition & 1 deletion packages/agent-bundle/tests/adapter-metadata.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ it('records exact immutable metadata for every built-in target', () => {
],
});
expect(registryMetadata(registry, 'codex')).toEqual({
adapterRevision: '1.11.0',
adapterRevision: '1.12.0',
observedVersion: '0.147.0',
schemas: [
{
Expand Down
139 changes: 3 additions & 136 deletions packages/workbench/src/overview-page.tsx
Original file line number Diff line number Diff line change
@@ -1,26 +1,8 @@
import React, { useState } from 'react';
import React from 'react';

import type { Diagnostic } from '../../agent-bundle/src/contracts/diagnostics.ts';
import type { ProjectStatus } from '../../agent-bundle/src/contracts/project.ts';

import { bundleSummaryFor, overviewFor, type OverviewHostAdoption } from './overview-model.ts';
import type { ProjectClient } from './project-client.ts';
import { bundleSummaryFor, type OverviewHostAdoption } from './overview-model.ts';
import type { WorkbenchCapabilities } from './workbench-capabilities.ts';
import { Navigation, Topbar, type WorkbenchPage } from './workbench-screen.tsx';

const dateTimeFormat = new Intl.DateTimeFormat(undefined, {
dateStyle: 'medium',
timeStyle: 'medium',
});

const dateTime = (value: string | undefined): string => value === undefined
? 'Not available'
: dateTimeFormat.format(new Date(value));

const stateLabel = (state: string): string => state.replaceAll('-', ' ');

const sourceFor = (diagnostic: Diagnostic): string =>
diagnostic.sourcePath ?? diagnostic.generatedPath ?? diagnostic.target ?? 'Project';
import type { WorkbenchPage } from './workbench-screen.tsx';

const StateMark = ({ state }: { readonly state: string }) => (
<span aria-hidden="true" className={`state-mark state-mark--${state}`}>{
Expand Down Expand Up @@ -110,118 +92,3 @@ export const BundleWorkflow = ({ capabilities, onNavigate }: {
</div>
</section>;
};

export const Overview = ({ capabilities, changedFiles, client, connectionError, onNavigate, pages, status, onStatus }: {
readonly capabilities?: WorkbenchCapabilities;
readonly changedFiles: readonly string[];
readonly client: ProjectClient;
readonly connectionError?: string;
readonly onNavigate: (page: WorkbenchPage) => void;
readonly onStatus: (status: ProjectStatus) => void;
readonly pages: ReadonlySet<WorkbenchPage>;
readonly status: ProjectStatus;
}) => {
const overview = overviewFor(status, changedFiles);
const [error, setError] = useState<string>();
const [rebuilding, setRebuilding] = useState(false);

const rebuild = async (): Promise<void> => {
setError(undefined);
setRebuilding(true);
try {
onStatus(await client.rebuild());
} catch (reason) {
setError(reason instanceof Error ? reason.message : 'Rebuild request could not be completed.');
} finally {
setRebuilding(false);
}
};

return (
<div className="workbench-shell">
<Navigation onNavigate={onNavigate} page="overview" pages={pages} />
<main className="canvas" id="overview">
<Topbar connectionError={connectionError} />
<div className="page-content">
<BundleWorkflow capabilities={capabilities} onNavigate={onNavigate} />

<section aria-labelledby="build-health-heading" className="build-health section">
<div>
<h2 id="build-health-heading">Build health</h2>
<div className="build-health-state">
<StateMark state={overview.epoch.state} />
<div><strong>{overview.epoch.summary}</strong><p>{overview.nextAction.summary}</p></div>
</div>
</div>
<button disabled={rebuilding} onClick={() => void rebuild()} type="button">
{rebuilding ? 'Rebuilding…' : overview.nextAction.label}
</button>
</section>
{error === undefined ? undefined : <p className="request-error" role="alert">{error}</p>}

<HostAdoptionSection hostAdoption={overview.hostAdoption} publishedEpochId={overview.epoch.id} />

<section aria-labelledby="diagnostics-heading" className="section">
<h2 id="diagnostics-heading">Diagnostics ({overview.diagnostics.length})</h2>
{overview.diagnostics.length === 0 ? <p className="empty-row">No source or latest-build diagnostics.</p> : (
<div className="table-wrap"><table>
<thead><tr><th>Severity</th><th>Code</th><th>Message</th><th>Source</th></tr></thead>
<tbody>{overview.diagnostics.map((diagnostic, index) => <tr key={`${diagnostic.code}-${index}`}>
<td><span className={`severity severity--${diagnostic.severity}`}>{diagnostic.severity}</span></td>
<td className="identifier">{diagnostic.code}</td>
<td>{diagnostic.message}</td>
<td className="identifier">{sourceFor(diagnostic)}</td>
</tr>)}</tbody>
</table></div>
)}
</section>

<details className="build-details">
<summary>Inspect build details</summary>
<section aria-labelledby="normalization-heading" className="section">
<h2 id="normalization-heading">Source and build state</h2>
<dl className="definition-row">
<div><dt>Source state</dt><dd><StateMark state={overview.normalization.state} />{overview.normalization.label}</dd></div>
<div><dt>Source revision</dt><dd className="identifier">{overview.normalization.revision ?? 'Not available'}</dd></div>
<div><dt>Build state</dt><dd className="status-text">{stateLabel(status.build.state)}</dd></div>
</dl>
</section>

<section aria-labelledby="published-build-heading" className="section">
<h2 id="published-build-heading">Published build</h2>
<div className={`epoch-row epoch-row--${overview.epoch.state}`}>
<div className="epoch-state"><StateMark state={overview.epoch.state} /><strong>{stateLabel(overview.epoch.state)}</strong></div>
<div><span>State</span><strong>{overview.epoch.summary}</strong></div>
<div><span>Build ID</span><strong className="identifier">{overview.epoch.id ?? 'None published'}</strong></div>
<div><span>Published</span><strong>{dateTime(overview.epoch.createdAt)}</strong></div>
</div>
</section>

<section aria-labelledby="targets-heading" className="section">
<h2 id="targets-heading">Generated targets</h2>
{overview.targets.length === 0 ? <p className="empty-row">No generated targets are available for this project state.</p> : (
<div className="table-wrap"><table>
<thead><tr><th>Target</th><th>Build state</th><th>Digest</th></tr></thead>
<tbody>{overview.targets.map((target) => <tr key={target.name}>
<td><strong>{target.name}</strong></td>
<td><StateMark state={target.state} />{stateLabel(target.state)}</td>
<td className="identifier">{target.digest}</td>
</tr>)}</tbody>
</table></div>
)}
</section>

<section aria-labelledby="changed-files-heading" className="section">
<h2 id="changed-files-heading">Latest changed files ({overview.changedFiles.length})</h2>
{overview.changedFiles.length === 0 ? <p className="empty-row">No source changes have been reported in this browser session.</p> : (
<ul className="changed-file-list">
{overview.changedFiles.map((path) => <li className="identifier" key={path}>{path}</li>)}
</ul>
)}
</section>
</details>
</div>
</main>
</div>
);
};
36 changes: 20 additions & 16 deletions packages/workbench/tests/overview-page.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,9 @@ import { renderToStaticMarkup } from 'react-dom/server';
import { expect, it } from '@rstest/core';

import type { ProjectStatus } from '../../agent-bundle/src/contracts/project.ts';
import { BundleWorkflow, Overview } from '../src/overview-page.tsx';
import type { ProjectClient } from '../src/project-client.ts';
import { overviewFor } from '../src/overview-model.ts';
import { BundleWorkflow, HostAdoptionSection } from '../src/overview-page.tsx';
import type { WorkbenchCapabilities } from '../src/workbench-capabilities.ts';
import type { WorkbenchPage } from '../src/workbench-screen.tsx';

const capabilities: Pick<WorkbenchCapabilities, 'counts' | 'pages'> = {
counts: { evalSuites: 1, hooks: 0, mcpServers: 0, scripts: 0, skills: 1, targets: 3 },
Expand Down Expand Up @@ -55,17 +54,20 @@ const activeStatus: ProjectStatus = {
source: { diagnostics: [], revision: 'revision-2', state: 'ready' },
};

const renderOverview = (status: ProjectStatus): string => renderToStaticMarkup(createElement(Overview, {
changedFiles: [],
client: {} as unknown as ProjectClient,
onNavigate: () => undefined,
onStatus: () => undefined,
pages: new Set<WorkbenchPage>(['overview']),
status,
}));
/**
* The Overview in main.tsx feeds `overviewFor(status)` into the shared
* HostAdoptionSection; this renders that section the same way.
*/
const renderHostAdoption = (status: ProjectStatus): string => {
const overview = overviewFor(status);
return renderToStaticMarkup(createElement(HostAdoptionSection, {
hostAdoption: overview.hostAdoption,
publishedEpochId: overview.epoch.id,
}));
};

it('renders a failed host-adoption gate with its violations instead of silently applying the build', () => {
const markup = renderOverview({
const status: ProjectStatus = {
...activeStatus,
hostAdoption: {
adoptedEpochId: 'epoch-1',
Expand All @@ -78,17 +80,19 @@ it('renders a failed host-adoption gate with its violations instead of silently
},
mode: 'gated',
},
});
};
const markup = renderHostAdoption(status);

expect(markup).toContain('Host adoption');
expect(markup).toContain('data-state="failed"');
expect(markup).toContain('Contract matrix failed for build epoch-2 with 1 violation; hosts keep build epoch-1');
expect(markup).toContain('tool:fixture/unknown');
expect(markup).toContain('coverage');
expect(markup).toContain('AB7211');
expect(markup).toContain('Diagnostics (1)');
expect(markup).toContain('epoch-2');
// The gate diagnostic reaches the Overview diagnostics table through the same model.
expect(overviewFor(status).diagnostics.map((diagnostic) => diagnostic.code)).toEqual(['AB7211']);
});

it('omits the host-adoption section when the foreground reports no host-facing surfaces', () => {
expect(renderOverview(activeStatus)).not.toContain('Host adoption');
expect(renderHostAdoption(activeStatus)).toBe('');
});
21 changes: 9 additions & 12 deletions website/plugins/generated-reference.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,14 @@ const mcpPathTokenFields = (host: JsonObject): JsonObject => {
return derived;
};

/**
* A host whose table records no MCP token fields because the adapter lowers
* the token instead of the host interpolating it says so in a dated
* `mcp.pathTokenLowering` row; its `reason` is the cell text.
*/
const mcpPathTokenLoweringNote = (host: JsonObject): string | undefined =>
asString(asObject(asObject(host.mcp).pathTokenLowering).reason);

const escapeProse = (text: string): string =>
text
.replaceAll('|', '\\|')
Expand Down Expand Up @@ -157,12 +165,6 @@ const messages = {
},
unavailable: 'unavailable',
notApplicable: '—',
// Hosts whose capability table records no MCP token fields because the
// adapter lowers the token instead of the host interpolating it.
mcpPathTokenNotes: {
codex:
'No host interpolation. `command`, `args`, `env` values, and `cwd` accept a *leading* `${PLUGIN_ROOT}` only when `cwd` is the plugin root; the compiler rewrites it to a `./`-relative path under `cwd: "./"`. Embedded tokens, `${PLUGIN_DATA}`, and workspace-root tokens are build errors.',
} as Readonly<Record<string, string>>,
evidenceNotes: (count: number) => `${count} evidence note${count === 1 ? '' : 's'}`,
eventsTitle: 'Event and hook matrix',
eventsDescription:
Expand Down Expand Up @@ -236,10 +238,6 @@ const messages = {
},
unavailable: 'unavailable',
notApplicable: '—',
mcpPathTokenNotes: {
codex:
'宿主不做插值。`command`、`args`、`env` 取值与 `cwd` 只在 `cwd` 为插件根时接受*开头*的 `${PLUGIN_ROOT}`;编译器会把它改写为 `cwd: "./"` 之下的 `./` 相对路径。嵌在中间的 token、`${PLUGIN_DATA}` 与工作区根 token 都是构建错误。',
} as Readonly<Record<string, string>>,
evidenceNotes: (count: number) => `${count} 条证据说明`,
eventsTitle: '事件与钩子矩阵',
eventsDescription:
Expand Down Expand Up @@ -462,12 +460,11 @@ function renderHosts(hosts: readonly HostCapabilityTable[], m: Messages): string
const fields = Object.entries(mcpPathTokenFields(host.data))
.map(([field, tokens]) => `${code(field)}: ${Array.isArray(tokens) ? codeList(tokens) : m.notApplicable}`)
.join('<br />');
const note = m.mcpPathTokenNotes[host.host];
return [
code(host.host),
mcp.stdio === true ? 'supported' : m.unavailable,
mcp.streamableHttp === true ? 'supported' : m.unavailable,
fields.length > 0 ? fields : note ?? m.notApplicable,
fields.length > 0 ? fields : mcpPathTokenLoweringNote(host.data) ?? m.notApplicable,
];
}),
),
Expand Down
Loading