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
18 changes: 8 additions & 10 deletions .eslint-plugin-local/code-no-untyped-meta-access.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,23 +14,21 @@ import { TSESTree } from '@typescript-eslint/utils';
* `x._meta?.['foo']`) or casting it to an interface (`x._meta as Foo`) bypasses
* any validation and lets well-known keys drift between producers and consumers.
*
* Instead, read well-known keys through a validating reader declared in a common
* module (e.g. `readToolCallMeta(toolCall)`), which takes the parent object,
* reads its `_meta` internally, checks each field, and drops wrong-typed values.
* Referencing `_meta` itself as a value (the leaf reference, e.g.
* `const meta = source._meta`) is allowed — only further member access or casts
* off `_meta` are flagged.
* Instead, read well-known keys through a validating reader declared under
* `common/meta` (e.g. `readToolCallMeta(toolCall)`), which takes the parent
* object, checks each recognized field, and drops wrong-typed values.
* Referencing `_meta` itself as a value is allowed; only further member access
* or casts directly off `_meta` are flagged.
*
* This rule is purely syntactic (no type information): it keys off the `_meta`
* identifier, so the reader modules that perform the one sanctioned first hop
* into a namespaced slot, and the rare access to a non-protocol `_meta` (e.g. a
* vendored SDK's own typed `_meta`), use a scoped `eslint-disable` line.
* identifier. Capturing a non-protocol `_meta` (for example, a vendored SDK's
* own typed metadata) in a local also keeps that distinction explicit.
*/
export default new class NoUntypedMetaAccess implements eslint.Rule.RuleModule {

readonly meta: eslint.Rule.RuleMetaData = {
messages: {
noMetaFieldAccess: 'Do not read fields off `_meta` directly. Read well-known keys through a validating reader that takes the parent object (e.g. `readToolCallMeta(toolCall)`) declared in a common module.',
noMetaFieldAccess: 'Do not read fields off `_meta` directly. Use a validating reader declared under `common/meta` (e.g. `readToolCallMeta(toolCall)`).',
noMetaCast: 'Do not cast `_meta` to an interface. Read well-known keys through a validating reader that takes the parent object (e.g. `readToolCallMeta(toolCall)`) declared in a common module.',
},
schema: false,
Expand Down
9 changes: 9 additions & 0 deletions .github/instructions/agentHostTesting.instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,15 @@ moving a node Agent Host service. It is the canonical guide for service
placement, static constructor arguments, activation, test overrides, and
disposal ownership.

## Protocol Metadata

Put new readers for namespaced protocol `_meta` slots under `common/meta`.
Readers must validate every value they return and expose typed data; callers
should pass the parent protocol object rather than inspecting `_meta` fields
directly. Existing unnamespaced and legacy readers live elsewhere and should be
migrated separately when touched. Use type guards for dynamic properties; do
not bypass type narrowing or lint rules with `Reflect.get`.

## End to End Testing

You can run `node ./scripts/code-agent-host.js` to start an agent host. If you pass `--enable-mock-agent`, then the `ScriptedMockAgent` will be used.
Expand Down
6 changes: 6 additions & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -391,6 +391,12 @@ export default defineConfig(
'**/test/**',
'**/*.test.ts',
'**/*.integrationTest.ts',
// This directory is the validation boundary for typed metadata
// readers. Callers elsewhere must consume those readers.
'src/vs/platform/agentHost/common/meta/**',
// Copilot SDK metadata is already typed and is not an AHP `_meta`
// bag. Keep its access isolated in one adapter.
'src/vs/platform/agentHost/node/copilot/copilotSdkMeta.ts',
// Codex's own generated app-server protocol (not AHP `_meta`).
'src/vs/platform/agentHost/node/codex/protocol/**',
],
Expand Down
118 changes: 1 addition & 117 deletions src/vs/platform/agentHost/common/codexAccount.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,120 +3,4 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import { URI } from '../../../base/common/uri.js';
import type { RootState } from './state/protocol/state.js';

export const CODEX_ACCOUNT_META_KEY = 'vscode.codexAccount';
export const CODEX_ACCOUNT_SIGN_IN_REQUEST_KEY = 'vscode.codexAccount.signInRequest';
export const CODEX_ACCOUNT_SIGN_OUT_REQUEST_KEY = 'vscode.codexAccount.signOutRequest';
export const CODEX_PROFILE_IMAGE_SCHEME = 'vscode-codex-profile-image';
export const MAX_CODEX_PROFILE_IMAGE_BYTES = 1024 * 1024;

const SUPPORTED_CODEX_PROFILE_IMAGE_MEDIA_TYPES = new Set([
'image/avif',
'image/gif',
'image/jpeg',
'image/png',
'image/webp',
]);

export interface ICodexProfileImageReference {
readonly uri: string;
readonly contentType: string;
readonly sizeHint: number;
readonly nonce: string;
}

export interface ICodexAccountRateLimitInfo {
readonly usedPercent: number;
readonly windowDurationMins?: number;
readonly resetsAt?: number;
}

export interface ICodexAccountInfo {
readonly status: 'unknown' | 'downloading' | 'signedIn' | 'signedOut' | 'unavailable' | 'error';
readonly email?: string;
readonly planType?: string;
readonly profileImage?: ICodexProfileImageReference;
readonly requiresOpenaiAuth?: boolean;
readonly rateLimit?: ICodexAccountRateLimitInfo;
readonly authUrl?: string;
readonly authUrlNonce?: string;
}

export function readCodexAccountInfo(state: RootState | undefined): ICodexAccountInfo {
// eslint-disable-next-line local/code-no-untyped-meta-access -- sanctioned reader for the namespaced Codex account slot; validated below.
const metaValue = state?._meta?.[CODEX_ACCOUNT_META_KEY];
const value = state?.config?.values[CODEX_ACCOUNT_META_KEY] ?? metaValue;
if (!value || typeof value !== 'object') {
return { status: 'unknown' };
}
const account = value as Partial<ICodexAccountInfo>;
if (account.status !== 'unknown' && account.status !== 'downloading' && account.status !== 'signedIn' && account.status !== 'signedOut' && account.status !== 'unavailable' && account.status !== 'error') {
return { status: 'unknown' };
}
const rateLimit = account.rateLimit;
const validRateLimit = rateLimit
&& typeof rateLimit === 'object'
&& typeof rateLimit.usedPercent === 'number'
&& Number.isFinite(rateLimit.usedPercent)
&& rateLimit.usedPercent >= 0
&& rateLimit.usedPercent <= 100
&& (rateLimit.windowDurationMins === undefined || (typeof rateLimit.windowDurationMins === 'number' && Number.isFinite(rateLimit.windowDurationMins) && rateLimit.windowDurationMins > 0))
&& (rateLimit.resetsAt === undefined || (typeof rateLimit.resetsAt === 'number' && Number.isFinite(rateLimit.resetsAt) && rateLimit.resetsAt > 0));
return {
status: account.status,
email: typeof account.email === 'string' ? account.email : undefined,
planType: typeof account.planType === 'string' ? account.planType : undefined,
profileImage: readProfileImageReference(account.profileImage),
requiresOpenaiAuth: typeof account.requiresOpenaiAuth === 'boolean' ? account.requiresOpenaiAuth : undefined,
rateLimit: validRateLimit ? {
usedPercent: rateLimit.usedPercent,
windowDurationMins: rateLimit.windowDurationMins,
resetsAt: rateLimit.resetsAt,
} : undefined,
authUrl: typeof account.authUrl === 'string' ? account.authUrl : undefined,
authUrlNonce: typeof account.authUrlNonce === 'string' ? account.authUrlNonce : undefined,
};
}

function readProfileImageReference(value: unknown): ICodexProfileImageReference | undefined {
if (!value || typeof value !== 'object') {
return undefined;
}
const reference = value as Partial<ICodexProfileImageReference>;
if (typeof reference.contentType !== 'string'
|| !SUPPORTED_CODEX_PROFILE_IMAGE_MEDIA_TYPES.has(reference.contentType)
|| typeof reference.sizeHint !== 'number'
|| !Number.isInteger(reference.sizeHint)
|| reference.sizeHint <= 0
|| reference.sizeHint > MAX_CODEX_PROFILE_IMAGE_BYTES
|| typeof reference.nonce !== 'string'
|| !/^[a-f0-9]{64}$/.test(reference.nonce)
|| !isProfileImageResourceUri(reference.uri, reference.contentType, reference.nonce)) {
return undefined;
}
return {
uri: reference.uri,
contentType: reference.contentType,
sizeHint: reference.sizeHint,
nonce: reference.nonce,
};
}

function isProfileImageResourceUri(value: unknown, contentType: string, nonce: string): value is string {
if (typeof value !== 'string') {
return false;
}
try {
const uri = URI.parse(value);
const extension = contentType === 'image/jpeg' ? 'jpg' : contentType.slice('image/'.length);
return uri.scheme === CODEX_PROFILE_IMAGE_SCHEME
&& !uri.authority
&& uri.path === `/profile-${nonce}.${extension}`
&& !uri.query
&& !uri.fragment;
} catch {
return false;
}
}
export * from './meta/codexAccount.js';
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@ export type IChatSurfaceMeta = ITerminalChatSurfaceMeta | IEditorInlineChatSurfa

/** Reads recognized chat-surface metadata, dropping malformed values. */
export function readChatSurfaceMeta(source: IHasChatSurfaceMeta): IChatSurfaceMeta | undefined {
// eslint-disable-next-line local/code-no-untyped-meta-access -- sanctioned first hop into the namespaced chat-surface slot.
const value = source._meta?.[VSCODE_CHAT_SURFACE_META_KEY];
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return undefined;
Expand Down
14 changes: 8 additions & 6 deletions src/vs/platform/agentHost/common/meta/agentElementAttachments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,13 @@ export function toElementAttachmentMeta(correlationId: string): Record<string, I
}

export function getElementAttachmentCorrelationId(attachment: { readonly _meta?: Record<string, unknown> }): string | undefined {
// eslint-disable-next-line local/code-no-untyped-meta-access -- sanctioned first hop into the namespaced element attachment slot; validated below.
const metadata = attachment._meta?.[AgentHostElementAttachmentMetadataKey];
// eslint-disable-next-line local/code-no-in-operator
if (!metadata || typeof metadata !== 'object' || !('correlationId' in metadata) || typeof metadata.correlationId !== 'string') {
return undefined;
}
return metadata.correlationId;
return isElementAttachmentMetadata(metadata) ? metadata.correlationId : undefined;
}

function isElementAttachmentMetadata(value: unknown): value is IAgentHostElementAttachmentMetadata {
return typeof value === 'object'
&& value !== null
&& 'correlationId' in value
&& typeof value.correlationId === 'string';
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ export interface IEphemeralSessionMeta {

/** Reads recognized ephemeral-session metadata, dropping wrong-typed values. */
export function readEphemeralSessionMeta(source: IHasEphemeralSessionMeta): IEphemeralSessionMeta {
// eslint-disable-next-line local/code-no-untyped-meta-access -- sanctioned first hop into the namespaced ephemeral-session slot.
const value = source._meta?.[VSCODE_EPHEMERAL_SESSION_META_KEY];
return typeof value === 'boolean' ? { isEphemeral: value } : {};
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,6 @@ export function getAgentFeedbackAttachmentMetadata(attachment: MessageAttachment
if (!isAgentFeedbackAttachment(attachment) && !isAgentFeedbackAnnotationsAttachment(attachment)) {
return undefined;
}
// eslint-disable-next-line local/code-no-untyped-meta-access -- sanctioned first hop into the namespaced feedback slot; validated below.
const metadata = attachment._meta?.[AgentFeedbackAttachmentMetadataKey];
if (!isRecord(metadata) || !isString(metadata.sessionResource) || !Array.isArray(metadata.feedbackItems)) {
return undefined;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ interface IHasAgentMergeMessageMeta {
* tells them apart.
*/
export function isAgentMergeMessage(source: IHasAgentMergeMessageMeta): boolean {
// eslint-disable-next-line local/code-no-untyped-meta-access -- sanctioned first hop into the namespaced Agent Merge slot; validated here.
return source._meta?.[AGENT_MERGE_MESSAGE_META_KEY] === true;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,6 @@ export function parseAgentMessageDelegationMeta(value: unknown): IAgentMessageDe

/** Reads recognized Agent Host message-delegation metadata. */
export function readAgentMessageDelegationMeta(source: IHasMessageDelegationMeta): IAgentMessageDelegationMeta | undefined {
// eslint-disable-next-line local/code-no-untyped-meta-access -- sanctioned first hop into the namespaced delegation slot; validated below.
return parseAgentMessageDelegationMeta(source._meta?.[MESSAGE_DELEGATION_META_KEY]);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,6 @@ export function toHostSnapshotAttachmentMeta(contentType: string | undefined): R
}

export function readHostSnapshotAttachmentMeta(attachment: { readonly _meta?: Record<string, unknown> }): IHostSnapshotAttachmentMetadata | undefined {
// eslint-disable-next-line local/code-no-untyped-meta-access -- sanctioned first hop into the namespaced snapshot attachment slot; validated below.
const metadata = attachment._meta?.[HostSnapshotAttachmentMetadataKey];
if (!isRecord(metadata) || metadata.isSnapshot !== true) {
return undefined;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ interface IHasAgentWorkspaceContinuationMeta {

/** Whether the message is the internal request that resumes a turn after workspace conversion. */
export function isAgentWorkspaceContinuationMessage(source: IHasAgentWorkspaceContinuationMeta): boolean {
// eslint-disable-next-line local/code-no-untyped-meta-access -- sanctioned first hop into the namespaced workspace-continuation slot; validated here.
return source._meta?.[AGENT_WORKSPACE_CONTINUATION_META_KEY] === true;
}

Expand Down
1 change: 0 additions & 1 deletion src/vs/platform/agentHost/common/meta/automationMeta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,5 @@ export function isAgentHostLegacyAutomationImportPending(source: IHasAutomationM
}

function readAutomationMetaSlot(source: IHasAutomationMeta, key: string): unknown {
// eslint-disable-next-line local/code-no-untyped-meta-access -- sanctioned reader for validated Automation metadata slots.
return source._meta?.[key];
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@ export function getBrowserViewAttachmentMetadata(attachment: MessageAttachment):
if (!isBrowserViewAttachment(attachment)) {
return undefined;
}
// eslint-disable-next-line local/code-no-untyped-meta-access -- sanctioned first hop into the namespaced browser view slot; validated below.
const metadata = attachment._meta?.[BrowserViewAttachmentMetadataKey];
if (!isRecord(metadata) || !isString(metadata.browserId) || !isString(metadata.browserUri)) {
return undefined;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ export function toClientPluginMcpDefaultCwdsMeta(defaultCwds: ClientPluginMcpDef
}

function readClientPluginMcpDefaultCwds(customization: ClientPluginCustomization): Record<string, unknown> | undefined {
// eslint-disable-next-line local/code-no-untyped-meta-access -- sanctioned reader for the namespaced MCP default-cwd slot; validated below.
const value = customization._meta?.[mcpDefaultCwdsKey];
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : undefined;
}
Expand Down
Loading